From 6a8b6c1d598ce0acbdad84794707a3c93870d1d8 Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:31:07 +0100 Subject: [PATCH 1/4] feat: sidenav component --- src/components/sidenav/Sidenav.tsx | 122 +++++++++++ .../components/sidenav/Sidenav.stories.tsx | 192 ++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 src/components/sidenav/Sidenav.tsx create mode 100644 src/stories/components/sidenav/Sidenav.stories.tsx diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx new file mode 100644 index 0000000..6f9d3ed --- /dev/null +++ b/src/components/sidenav/Sidenav.tsx @@ -0,0 +1,122 @@ +import { Icon, IconWeight, DotsNineIcon } from '@phosphor-icons/react'; +import { ReactNode } from 'react'; + +export interface SidenavOption { + id: number; + title: string; + icon: Icon; + iconSize?: number; + weight?: IconWeight; + notifications?: number; + subsection?: boolean; +} + +export interface SidenavHeader { + logo: string; + title: string; +} + +export interface SidenavStorage { + used: string; + total: string; + percentage: number; + onUpgradeClick: () => void; + upgradeLabel: string; +} + +export interface SidenavProps { + header: SidenavHeader; + primaryAction?: ReactNode; + options: SidenavOption[]; + activeOptionId: number; + showSubsections: boolean; + storage?: SidenavStorage; + onOptionClick: (optionId: number, isSubsection: boolean) => void; + onMenuClick: () => void; +} + +export const Sidenav = ({ + header, + primaryAction, + options, + activeOptionId, + showSubsections, + storage, + onOptionClick, + onMenuClick, +}: SidenavProps) => { + return ( +
+
+
+
+ {header.title} +

{header.title}

+
+ +
+
+ {primaryAction} +
+ {options.map((option) => { + if (option.subsection && !showSubsections) { + return null; + } + + const isActive = activeOptionId === option.id; + + return ( + + ); + })} +
+
+
+ {storage && ( +
+
+
+

{storage.used}

+

/

+

{storage.total}

+
+ +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/stories/components/sidenav/Sidenav.stories.tsx b/src/stories/components/sidenav/Sidenav.stories.tsx new file mode 100644 index 0000000..a6e68c1 --- /dev/null +++ b/src/stories/components/sidenav/Sidenav.stories.tsx @@ -0,0 +1,192 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useArgs } from 'storybook/preview-api'; +import { Sidenav, SidenavProps } from '@/components/sidenav/Sidenav'; +import { Button } from '@/components/button'; +import { + TrayIcon, + PaperPlaneTiltIcon, + FileIcon, + TrashIcon, + CaretDownIcon, + StarIcon, + TagIcon, + NotePencilIcon, +} from '@phosphor-icons/react'; + +const meta: Meta = { + title: 'Components/Sidenav', + component: Sidenav, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + argTypes: { + onOptionClick: { action: 'optionClicked' }, + onMenuClick: { action: 'menuClicked' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +const MAIL_OPTIONS = [ + { + id: 0, + title: 'Inbox', + icon: TrayIcon, + notifications: 12, + }, + { + id: 1, + title: 'Sent', + icon: PaperPlaneTiltIcon, + }, + { + id: 2, + title: 'Drafts', + icon: FileIcon, + notifications: 3, + }, + { + id: 3, + title: 'Trash', + icon: TrashIcon, + }, + { + id: 4, + title: 'Labels', + icon: CaretDownIcon, + }, + { + id: 5, + title: 'Important', + icon: StarIcon, + subsection: true, + }, + { + id: 6, + title: 'Work', + icon: TagIcon, + subsection: true, + }, + { + id: 7, + title: 'Personal', + icon: TagIcon, + subsection: true, + }, +]; + +const InteractiveSidenav = (args: SidenavProps) => { + const [{ activeOptionId, showSubsections }, setArgs] = useArgs(); + + const handleOptionClick = (optionId: number, isSubsection: boolean) => { + if (isSubsection) { + setArgs({ activeOptionId: optionId }); + return; + } + + if (optionId === 4) { + setArgs({ activeOptionId: optionId, showSubsections: !showSubsections }); + } else { + setArgs({ activeOptionId: optionId, showSubsections: false }); + } + }; + + return ( + + ); +}; + +export const Default: Story = { + render: InteractiveSidenav, + args: { + header: { + logo: 'https://internxt.com/favicon.ico', + title: 'Mail', + }, + primaryAction: ( + + ), + options: MAIL_OPTIONS, + activeOptionId: 0, + showSubsections: false, + storage: { + used: '2.8 GB', + total: '4 GB', + percentage: 70, + upgradeLabel: 'Upgrade', + onUpgradeClick: () => console.log('Upgrade clicked'), + }, + }, +}; + +export const WithSubsectionsExpanded: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + activeOptionId: 4, + showSubsections: true, + }, +}; + +export const WithoutPrimaryAction: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + primaryAction: undefined, + }, +}; + +export const WithoutStorage: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + storage: undefined, + }, +}; + +export const HighStorageUsage: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + storage: { + used: '9.5 GB', + total: '10 GB', + percentage: 95, + upgradeLabel: 'Upgrade now', + onUpgradeClick: () => console.log('Upgrade clicked'), + }, + }, +}; + +export const Minimal: Story = { + render: InteractiveSidenav, + args: { + header: { + logo: 'https://internxt.com/favicon.ico', + title: 'Drive', + }, + options: [ + { id: 0, title: 'All files', icon: FileIcon }, + { id: 1, title: 'Trash', icon: TrashIcon }, + ], + activeOptionId: 0, + showSubsections: false, + }, +}; From 9242eea3f5dbf0b2acabb88967a776c4e193bae9 Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:51:13 +0100 Subject: [PATCH 2/4] feat: allow collapsing the sidenav --- src/components/sidenav/Sidenav.tsx | 55 ++++++++++++++----- .../components/sidenav/Sidenav.stories.tsx | 39 ++++++++++++- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx index 6f9d3ed..6baa529 100644 --- a/src/components/sidenav/Sidenav.tsx +++ b/src/components/sidenav/Sidenav.tsx @@ -1,4 +1,4 @@ -import { Icon, IconWeight, DotsNineIcon } from '@phosphor-icons/react'; +import { Icon, IconWeight, DotsNineIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'; import { ReactNode } from 'react'; export interface SidenavOption { @@ -27,44 +27,68 @@ export interface SidenavStorage { export interface SidenavProps { header: SidenavHeader; primaryAction?: ReactNode; + collapsedPrimaryAction?: ReactNode; options: SidenavOption[]; activeOptionId: number; showSubsections: boolean; + isCollapsed?: boolean; storage?: SidenavStorage; onOptionClick: (optionId: number, isSubsection: boolean) => void; onMenuClick: () => void; + onToggleCollapse?: () => void; } export const Sidenav = ({ header, primaryAction, + collapsedPrimaryAction, options, activeOptionId, showSubsections, + isCollapsed = false, storage, onOptionClick, onMenuClick, + onToggleCollapse, }: SidenavProps) => { return ( -
+
+ {onToggleCollapse && ( + + )}
-
+
{header.title} -

{header.title}

+ {!isCollapsed &&

{header.title}

}
- + {!isCollapsed && ( + + )}
- {primaryAction} + {isCollapsed ? collapsedPrimaryAction : primaryAction}
{options.map((option) => { if (option.subsection && !showSubsections) { return null; } + if (isCollapsed && option.subsection) { + return null; + } + const isActive = activeOptionId === option.id; return ( @@ -74,13 +98,18 @@ export const Sidenav = ({ isActive ? 'bg-primary/20' : 'hover:bg-gray-5' } ${option.subsection ? 'pl-5' : ''}`} onClick={() => onOptionClick(option.id, !!option.subsection)} + title={isCollapsed ? option.title : undefined} > -
-
+
+
-

{option.title}

+ {!isCollapsed &&

{option.title}

}
- {option.notifications && ( + {!isCollapsed && option.notifications && (

{option.notifications}

@@ -92,7 +121,7 @@ export const Sidenav = ({
- {storage && ( + {!isCollapsed && storage && (
diff --git a/src/stories/components/sidenav/Sidenav.stories.tsx b/src/stories/components/sidenav/Sidenav.stories.tsx index a6e68c1..73e2d8c 100644 --- a/src/stories/components/sidenav/Sidenav.stories.tsx +++ b/src/stories/components/sidenav/Sidenav.stories.tsx @@ -23,10 +23,11 @@ const meta: Meta = { argTypes: { onOptionClick: { action: 'optionClicked' }, onMenuClick: { action: 'menuClicked' }, + onToggleCollapse: { action: 'toggleCollapse' }, }, decorators: [ (Story) => ( -
+
), @@ -85,7 +86,7 @@ const MAIL_OPTIONS = [ ]; const InteractiveSidenav = (args: SidenavProps) => { - const [{ activeOptionId, showSubsections }, setArgs] = useArgs(); + const [{ activeOptionId, showSubsections, isCollapsed }, setArgs] = useArgs(); const handleOptionClick = (optionId: number, isSubsection: boolean) => { if (isSubsection) { @@ -100,12 +101,20 @@ const InteractiveSidenav = (args: SidenavProps) => { } }; + const handleToggleCollapse = args.onToggleCollapse + ? () => { + setArgs({ isCollapsed: !isCollapsed }); + } + : undefined; + return ( ); }; @@ -123,9 +132,15 @@ export const Default: Story = {

New message

), + collapsedPrimaryAction: ( + + ), options: MAIL_OPTIONS, activeOptionId: 0, showSubsections: false, + isCollapsed: false, storage: { used: '2.8 GB', total: '4 GB', @@ -133,6 +148,15 @@ export const Default: Story = { upgradeLabel: 'Upgrade', onUpgradeClick: () => console.log('Upgrade clicked'), }, + onToggleCollapse: () => {}, + }, +}; + +export const Collapsed: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + isCollapsed: true, }, }; @@ -150,6 +174,7 @@ export const WithoutPrimaryAction: Story = { args: { ...Default.args, primaryAction: undefined, + collapsedPrimaryAction: undefined, }, }; @@ -188,5 +213,15 @@ export const Minimal: Story = { ], activeOptionId: 0, showSubsections: false, + isCollapsed: false, + onToggleCollapse: () => {}, + }, +}; + +export const WithoutCollapseButton: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + onToggleCollapse: undefined, }, }; From 5a55f51631ff0c7ce64a1fd8ee6c3a1f0002365b Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:54:25 +0100 Subject: [PATCH 3/4] test: add coverage for sidenav --- .../sidenav/__test__/Sidenav.test.tsx | 243 +++++++ .../__snapshots__/Sidenav.test.tsx.snap | 620 ++++++++++++++++++ 2 files changed, 863 insertions(+) create mode 100644 src/components/sidenav/__test__/Sidenav.test.tsx create mode 100644 src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap diff --git a/src/components/sidenav/__test__/Sidenav.test.tsx b/src/components/sidenav/__test__/Sidenav.test.tsx new file mode 100644 index 0000000..7bab16e --- /dev/null +++ b/src/components/sidenav/__test__/Sidenav.test.tsx @@ -0,0 +1,243 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Sidenav, SidenavProps, SidenavOption } from '../Sidenav'; + +const MockIcon = React.forwardRef( + ({ size = 20 }, ref) => , +); + +describe('Sidenav Component', () => { + const onOptionClick = vi.fn(); + const onMenuClick = vi.fn(); + const onToggleCollapse = vi.fn(); + const onUpgradeClick = vi.fn(); + + const mockOptions: SidenavOption[] = [ + { id: 0, title: 'Inbox', icon: MockIcon, notifications: 5 }, + { id: 1, title: 'Sent', icon: MockIcon }, + { id: 2, title: 'Drafts', icon: MockIcon, notifications: 2 }, + { id: 3, title: 'Labels', icon: MockIcon }, + { id: 4, title: 'Important', icon: MockIcon, subsection: true }, + { id: 5, title: 'Work', icon: MockIcon, subsection: true }, + ]; + + const defaultProps: SidenavProps = { + header: { + logo: 'https://example.com/logo.png', + title: 'Mail', + }, + options: mockOptions, + activeOptionId: 0, + showSubsections: false, + onOptionClick, + onMenuClick, + }; + + const renderSidenav = (props: Partial = {}) => + render(); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should match snapshot', () => { + const { container } = renderSidenav(); + expect(container).toMatchSnapshot(); + }); + + it('should match snapshot when collapsed', () => { + const { container } = renderSidenav({ isCollapsed: true, onToggleCollapse }); + expect(container).toMatchSnapshot(); + }); + + it('should match snapshot with storage', () => { + const { container } = renderSidenav({ + storage: { + used: '2.8 GB', + total: '4 GB', + percentage: 70, + upgradeLabel: 'Upgrade', + onUpgradeClick, + }, + }); + expect(container).toMatchSnapshot(); + }); + + it('should match snapshot with primary action', () => { + const { container } = renderSidenav({ + primaryAction: , + }); + expect(container).toMatchSnapshot(); + }); + + it('renders header with logo and title', () => { + const { getByAltText, getByText } = renderSidenav(); + expect(getByAltText('Mail')).toBeInTheDocument(); + expect(getByText('Mail')).toBeInTheDocument(); + }); + + it('renders all non-subsection options', () => { + const { getByText, queryByText } = renderSidenav(); + expect(getByText('Inbox')).toBeInTheDocument(); + expect(getByText('Sent')).toBeInTheDocument(); + expect(getByText('Drafts')).toBeInTheDocument(); + expect(getByText('Labels')).toBeInTheDocument(); + expect(queryByText('Important')).not.toBeInTheDocument(); + expect(queryByText('Work')).not.toBeInTheDocument(); + }); + + it('renders subsections when showSubsections is true', () => { + const { getByText } = renderSidenav({ showSubsections: true }); + expect(getByText('Important')).toBeInTheDocument(); + expect(getByText('Work')).toBeInTheDocument(); + }); + + it('renders notifications badge', () => { + const { getByText } = renderSidenav(); + expect(getByText('5')).toBeInTheDocument(); + expect(getByText('2')).toBeInTheDocument(); + }); + + it('calls onOptionClick when option is clicked', () => { + const { getByText } = renderSidenav(); + fireEvent.click(getByText('Inbox')); + expect(onOptionClick).toHaveBeenCalledWith(0, false); + }); + + it('calls onOptionClick with isSubsection true for subsection items', () => { + const { getByText } = renderSidenav({ showSubsections: true }); + fireEvent.click(getByText('Important')); + expect(onOptionClick).toHaveBeenCalledWith(4, true); + }); + + it('calls onMenuClick when menu button is clicked', () => { + const { container } = renderSidenav(); + const menuButton = container.querySelector('button:has(svg)'); + fireEvent.click(menuButton!); + expect(onMenuClick).toHaveBeenCalled(); + }); + + it('renders primary action when provided', () => { + const { getByTestId } = renderSidenav({ + primaryAction: , + }); + expect(getByTestId('primary-action')).toBeInTheDocument(); + }); + + it('renders storage information when provided', () => { + const { getByText } = renderSidenav({ + storage: { + used: '2.8 GB', + total: '4 GB', + percentage: 70, + upgradeLabel: 'Upgrade', + onUpgradeClick, + }, + }); + expect(getByText('2.8 GB')).toBeInTheDocument(); + expect(getByText('4 GB')).toBeInTheDocument(); + expect(getByText('Upgrade')).toBeInTheDocument(); + }); + + it('calls onUpgradeClick when upgrade button is clicked', () => { + const { getByText } = renderSidenav({ + storage: { + used: '2.8 GB', + total: '4 GB', + percentage: 70, + upgradeLabel: 'Upgrade', + onUpgradeClick, + }, + }); + fireEvent.click(getByText('Upgrade')); + expect(onUpgradeClick).toHaveBeenCalled(); + }); + + it('applies active styles to selected option', () => { + const { getByText } = renderSidenav({ activeOptionId: 0 }); + const inboxButton = getByText('Inbox').closest('button'); + expect(inboxButton).toHaveClass('bg-primary/20'); + }); + + describe('Collapsed state', () => { + it('hides title when collapsed', () => { + const { queryByText } = renderSidenav({ isCollapsed: true }); + expect(queryByText('Mail')).not.toBeInTheDocument(); + }); + + it('hides option titles when collapsed', () => { + const { queryByText } = renderSidenav({ isCollapsed: true }); + expect(queryByText('Inbox')).not.toBeInTheDocument(); + expect(queryByText('Sent')).not.toBeInTheDocument(); + }); + + it('hides notifications when collapsed', () => { + const { queryByText } = renderSidenav({ isCollapsed: true }); + expect(queryByText('5')).not.toBeInTheDocument(); + }); + + it('hides storage when collapsed', () => { + const { queryByText } = renderSidenav({ + isCollapsed: true, + storage: { + used: '2.8 GB', + total: '4 GB', + percentage: 70, + upgradeLabel: 'Upgrade', + onUpgradeClick, + }, + }); + expect(queryByText('2.8 GB')).not.toBeInTheDocument(); + expect(queryByText('Upgrade')).not.toBeInTheDocument(); + }); + + it('hides subsections when collapsed even if showSubsections is true', () => { + const { container } = renderSidenav({ + isCollapsed: true, + showSubsections: true, + }); + const buttons = container.querySelectorAll('button'); + // Should only have non-subsection options (4) + collapse button if provided + expect(buttons.length).toBe(4); + }); + + it('shows collapsed primary action when collapsed', () => { + const { getByTestId, queryByTestId } = renderSidenav({ + isCollapsed: true, + primaryAction: , + collapsedPrimaryAction: , + }); + expect(queryByTestId('primary-action')).not.toBeInTheDocument(); + expect(getByTestId('collapsed-primary-action')).toBeInTheDocument(); + }); + + it('adds title attribute to options when collapsed for tooltip', () => { + const { container } = renderSidenav({ isCollapsed: true }); + const optionButtons = container.querySelectorAll('button[title]'); + expect(optionButtons.length).toBeGreaterThan(0); + expect(optionButtons[0]).toHaveAttribute('title', 'Inbox'); + }); + }); + + describe('Collapse toggle button', () => { + it('does not render collapse button when onToggleCollapse is not provided', () => { + const { container } = renderSidenav(); + const collapseButton = container.querySelector('.absolute'); + expect(collapseButton).not.toBeInTheDocument(); + }); + + it('renders collapse button when onToggleCollapse is provided', () => { + const { container } = renderSidenav({ onToggleCollapse }); + const collapseButton = container.querySelector('.absolute'); + expect(collapseButton).toBeInTheDocument(); + }); + + it('calls onToggleCollapse when collapse button is clicked', () => { + const { container } = renderSidenav({ onToggleCollapse }); + const collapseButton = container.querySelector('.absolute'); + fireEvent.click(collapseButton!); + expect(onToggleCollapse).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap b/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap new file mode 100644 index 0000000..76db4a4 --- /dev/null +++ b/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap @@ -0,0 +1,620 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Sidenav Component > should match snapshot 1`] = ` +
+
+
+
+
+ Mail +

+ Mail +

+
+ +
+
+
+ + + + +
+
+
+
+
+`; + +exports[`Sidenav Component > should match snapshot when collapsed 1`] = ` +
+
+ +
+
+
+ Mail +
+
+
+
+ + + + +
+
+
+
+
+`; + +exports[`Sidenav Component > should match snapshot with primary action 1`] = ` +
+
+
+
+
+ Mail +

+ Mail +

+
+ +
+
+ +
+ + + + +
+
+
+
+
+`; + +exports[`Sidenav Component > should match snapshot with storage 1`] = ` +
+
+
+
+
+ Mail +

+ Mail +

+
+ +
+
+
+ + + + +
+
+
+
+
+
+

+ 2.8 GB +

+

+ / +

+

+ 4 GB +

+
+ +
+
+
+
+
+
+
+`; From dbd062a8cf54d4827c4205295f1c35c54db7bec1 Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:56:38 +0100 Subject: [PATCH 4/4] fix: upgrade button --- src/components/sidenav/Sidenav.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx index 6baa529..e31f151 100644 --- a/src/components/sidenav/Sidenav.tsx +++ b/src/components/sidenav/Sidenav.tsx @@ -21,7 +21,7 @@ export interface SidenavStorage { total: string; percentage: number; onUpgradeClick: () => void; - upgradeLabel: string; + upgradeLabel?: string; } export interface SidenavProps { @@ -103,9 +103,7 @@ export const Sidenav = ({
-
+
{!isCollapsed &&

{option.title}

}
@@ -129,12 +127,14 @@ export const Sidenav = ({

/

{storage.total}

- + {storage.upgradeLabel && ( + + )}