From b453dfe1078eaeab7ea30af2feb7e0eb3e2392cf 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 01/15] 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 dcdff52a54c5ecece45330cb8e8a1c4ca8bcd143 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 02/15] 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 32a235a5bed070d7547741a62bffc7fe291cbecc 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 03/15] 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 dc9f2e7022c05f95986c1dbdbffa1cbfe4b4f5a7 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 04/15] 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 && ( + + )}
Date: Wed, 25 Feb 2026 09:35:32 +0100 Subject: [PATCH 05/15] feat: add JSDoc for Sidenav component --- src/components/sidenav/Sidenav.tsx | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx index e31f151..112a523 100644 --- a/src/components/sidenav/Sidenav.tsx +++ b/src/components/sidenav/Sidenav.tsx @@ -38,6 +38,44 @@ export interface SidenavProps { onToggleCollapse?: () => void; } +/** + * Sidenav component + * + * A custom sidenav component that provides a sidebar with options for navigation and interaction. + * + * @property {SidenavHeader} header + * - Determines whether to display the header section of the sidenav. + * + * @property {ReactNode} primaryAction + * - The primary action displayed at the top of the sidenav. + * + * @property {ReactNode} collapsedPrimaryAction + * - The primary action displayed when the sidenav is collapsed. + * + * @property {SidenavOption[]} options + * - An array of options to be displayed in the sidenav. + * + * @property {number} activeOptionId + * - The ID of the currently active option in the sidenav. + * + * @property {boolean} showSubsections + * - Determines whether to display the subsections of the sidenav. + * + * @property {boolean} isCollapsed + * - Determines whether the sidenav is collapsed or not. + * + * @property {SidenavStorage} storage + * - The storage information displayed at the bottom of the sidenav. + * + * @property {(optionId: number, isSubsection: boolean) => void} onOptionClick + * - A callback function triggered when an option in the sidenav is clicked. + * + * @property {() => void} onMenuClick + * - A callback function triggered when the menu button is clicked. + * + * @property {() => void} onToggleCollapse + * - A callback function triggered when the collapse button is clicked. + */ export const Sidenav = ({ header, primaryAction, From 6b74cb79d88ddc34196fbe5ba6475c51eb516da6 Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:16:40 +0100 Subject: [PATCH 06/15] chore: bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 503d3a9..7fb537c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internxt/ui", - "version": "0.1.4", + "version": "0.1.5", "description": "Library of Internxt components", "repository": { "type": "git", From 96fabf30a778409346a7ef73a9d088861ce7dedd Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:25:38 +0100 Subject: [PATCH 07/15] chore: deactivate max length rule --- eslint.config.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 909af66..bb7ced0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,13 +7,13 @@ export default [ ...eslintConfigInternxt, { languageOptions: { - parser: typescriptParser + parser: typescriptParser, }, rules: { '@typescript-eslint/no-explicit-any': 'warn', 'react/react-in-jsx-scope': 'off', + 'max-len': 'off', }, ignores: ['dist', 'tmp', 'scripts', 'node_modules', '!.storybook'], - } + }, ]; - From 611e1c11611a0df44cc7d2966301b57fed6e9fdf Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:27:35 +0100 Subject: [PATCH 08/15] feat: upgrading component --- src/components/sidenav/Sidenav.tsx | 176 ++++++++++++++--------------- 1 file changed, 87 insertions(+), 89 deletions(-) diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx index 112a523..b6f0ddd 100644 --- a/src/components/sidenav/Sidenav.tsx +++ b/src/components/sidenav/Sidenav.tsx @@ -88,102 +88,100 @@ export const Sidenav = ({ onOptionClick, onMenuClick, onToggleCollapse, -}: SidenavProps) => { - return ( -
- {onToggleCollapse && ( - - )} -
-
-
- {header.title} - {!isCollapsed &&

{header.title}

} -
- {!isCollapsed && ( - - )} +}: SidenavProps) => ( +
+ {onToggleCollapse && ( + + )} +
+
+
+ {header.title} + {!isCollapsed &&

{header.title}

}
-
- {isCollapsed ? collapsedPrimaryAction : primaryAction} -
- {options.map((option) => { - if (option.subsection && !showSubsections) { - return null; - } + {!isCollapsed && ( + + )} +
+
+ {isCollapsed ? collapsedPrimaryAction : primaryAction} +
+ {options.map((option) => { + if (option.subsection && !showSubsections) { + return null; + } - if (isCollapsed && option.subsection) { - return null; - } + if (isCollapsed && option.subsection) { + return null; + } - const isActive = activeOptionId === option.id; + const isActive = activeOptionId === option.id; - return ( - - ); - })} -
+ {!isCollapsed && option.notifications && ( +
+

{option.notifications}

+
+ )} +
+ + ); + })}
- {!isCollapsed && storage && ( -
-
-
-

{storage.used}

-

/

-

{storage.total}

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

{storage.used}

+

/

+

{storage.total}

+ {storage.upgradeLabel && ( + + )}
- )} -
- ); -}; +
+
+
+
+ )} +
+); From 9fe2fdd72a6cd91922c78b691888bd91c1afc9b7 Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:30:08 +0100 Subject: [PATCH 09/15] feat: add on click option for logo --- src/components/sidenav/Sidenav.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx index b6f0ddd..2a9975d 100644 --- a/src/components/sidenav/Sidenav.tsx +++ b/src/components/sidenav/Sidenav.tsx @@ -14,6 +14,7 @@ export interface SidenavOption { export interface SidenavHeader { logo: string; title: string; + onClick: () => void; } export interface SidenavStorage { @@ -104,10 +105,10 @@ export const Sidenav = ({ )}
-
+
+ {!isCollapsed && ( - {!isCollapsed && ( - + {!isCollapsed && suiteLauncher && ( + )}
diff --git a/src/components/suiteLauncher/SuiteLauncher.tsx b/src/components/suiteLauncher/SuiteLauncher.tsx index e0f5a75..52b16e5 100644 --- a/src/components/suiteLauncher/SuiteLauncher.tsx +++ b/src/components/suiteLauncher/SuiteLauncher.tsx @@ -1,4 +1,4 @@ -import { DotsNine, Lock } from '@phosphor-icons/react'; +import { DotsNineIcon, Lock } from '@phosphor-icons/react'; import { cloneElement, isValidElement } from 'react'; import { Popover } from '../popover'; @@ -13,6 +13,7 @@ export interface SuiteLauncherProps { isLocked?: boolean; }[]; soonText?: string; + align?: 'left' | 'right'; } /** @@ -37,10 +38,11 @@ export default function SuiteLauncher({ className = '', suiteArray, soonText, + align = 'right', }: Readonly): JSX.Element { const SuiteButton = ( -
- +
+
); @@ -54,7 +56,7 @@ export default function SuiteLauncher({
{suiteApp.isLocked ? ( - + ) : isValidElement(suiteApp.icon as JSX.Element) ? ( cloneElement(suiteApp.icon as JSX.Element, { size: 26, @@ -102,6 +109,12 @@ export default function SuiteLauncher({ ); return ( - panel} data-testid="app-suite-dropdown" /> + panel} + align={align} + data-testid="app-suite-dropdown" + /> ); } diff --git a/src/stories/components/sidenav/Sidenav.stories.tsx b/src/stories/components/sidenav/Sidenav.stories.tsx index 73e2d8c..b6e5ede 100644 --- a/src/stories/components/sidenav/Sidenav.stories.tsx +++ b/src/stories/components/sidenav/Sidenav.stories.tsx @@ -11,8 +11,38 @@ import { StarIcon, TagIcon, NotePencilIcon, + HardDrivesIcon, + EnvelopeSimpleIcon, + ShieldCheckIcon, + LockKeyIcon, } from '@phosphor-icons/react'; +const SUITE_ARRAY = [ + { + icon: , + title: 'Drive', + onClick: () => console.log('Drive clicked'), + }, + { + icon: , + title: 'Mail', + onClick: () => console.log('Mail clicked'), + isMain: true, + }, + { + icon: , + title: 'VPN', + onClick: () => console.log('VPN clicked'), + availableSoon: true, + }, + { + icon: , + title: 'Pass', + onClick: () => console.log('Pass clicked'), + isLocked: true, + }, +]; + const meta: Meta = { title: 'Components/Sidenav', component: Sidenav, @@ -22,7 +52,6 @@ const meta: Meta = { tags: ['autodocs'], argTypes: { onOptionClick: { action: 'optionClicked' }, - onMenuClick: { action: 'menuClicked' }, onToggleCollapse: { action: 'toggleCollapse' }, }, decorators: [ @@ -125,6 +154,7 @@ export const Default: Story = { header: { logo: 'https://internxt.com/favicon.ico', title: 'Mail', + onClick: () => console.log('Header clicked'), }, primaryAction: ( ), + suiteLauncher: { + suiteArray: SUITE_ARRAY, + soonText: 'Soon', + }, options: MAIL_OPTIONS, activeOptionId: 0, showSubsections: false, @@ -206,6 +240,7 @@ export const Minimal: Story = { header: { logo: 'https://internxt.com/favicon.ico', title: 'Drive', + onClick: () => console.log('Header clicked'), }, options: [ { id: 0, title: 'All files', icon: FileIcon }, From 0496168f189e489906f9af62e3f09b4527d0434a Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:31:35 +0100 Subject: [PATCH 12/15] feat: update component --- src/components/suiteLauncher/SuiteLauncher.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/suiteLauncher/SuiteLauncher.tsx b/src/components/suiteLauncher/SuiteLauncher.tsx index 52b16e5..22e84aa 100644 --- a/src/components/suiteLauncher/SuiteLauncher.tsx +++ b/src/components/suiteLauncher/SuiteLauncher.tsx @@ -1,4 +1,4 @@ -import { DotsNineIcon, Lock } from '@phosphor-icons/react'; +import { DotsNineIcon, LockIcon } from '@phosphor-icons/react'; import { cloneElement, isValidElement } from 'react'; import { Popover } from '../popover'; @@ -64,7 +64,7 @@ export default function SuiteLauncher({ >
{suiteApp.isLocked ? ( - Date: Thu, 26 Feb 2026 16:42:06 +0100 Subject: [PATCH 13/15] refactor: separate the components --- src/components/index.ts | 1 + src/components/sidenav/Sidenav.tsx | 212 ++---- src/components/sidenav/SidenavHeader.tsx | 85 +++ src/components/sidenav/SidenavItem.tsx | 67 ++ src/components/sidenav/SidenavOptions.tsx | 63 ++ src/components/sidenav/SidenavStorage.tsx | 42 ++ .../__snapshots__/Sidenav.test.tsx.snap | 620 ------------------ src/components/sidenav/index.ts | 4 + .../__snapshots__/SuiteLauncher.test.tsx.snap | 4 +- .../components/sidenav/Sidenav.stories.tsx | 98 ++- 10 files changed, 386 insertions(+), 810 deletions(-) create mode 100644 src/components/sidenav/SidenavHeader.tsx create mode 100644 src/components/sidenav/SidenavItem.tsx create mode 100644 src/components/sidenav/SidenavOptions.tsx create mode 100644 src/components/sidenav/SidenavStorage.tsx delete mode 100644 src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap create mode 100644 src/components/sidenav/index.ts diff --git a/src/components/index.ts b/src/components/index.ts index 0a49e0b..f68b021 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -27,3 +27,4 @@ export * from './switch'; export * from './table/Table'; export * from './textArea'; export * from './tooltip'; +export * from './sidenav'; diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx index 7b65387..31daf53 100644 --- a/src/components/sidenav/Sidenav.tsx +++ b/src/components/sidenav/Sidenav.tsx @@ -1,26 +1,18 @@ -import { Icon, IconWeight, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'; -import { ReactNode } from 'react'; -import { SuiteLauncher } from '../suiteLauncher'; - -export interface SidenavOption { - id: number; - title: string; - icon: Icon; - iconSize?: number; - weight?: IconWeight; - notifications?: number; - subsection?: boolean; -} +import { ComponentType, ReactNode } from 'react'; +import SidenavOptions, { SidenavOption } from './SidenavOptions'; +import SidenavHeader from './SidenavHeader'; +import SidenavStorage from './SidenavStorage'; export interface SidenavHeader { logo: string; title: string; onClick: () => void; + className?: string; } export interface SidenavStorage { - used: string; - total: string; + usage: string; + limit: string; percentage: number; onUpgradeClick: () => void; upgradeLabel?: string; @@ -43,12 +35,11 @@ export interface SidenavProps { }; collapsedPrimaryAction?: ReactNode; options: SidenavOption[]; - activeOptionId: number; - showSubsections: boolean; + showSubsections?: boolean; isCollapsed?: boolean; storage?: SidenavStorage; - onOptionClick: (optionId: number, isSubsection: boolean) => void; onToggleCollapse?: () => void; + LinkComponent?: ComponentType<{ to: string; className?: string; children: ReactNode }>; } /** @@ -56,151 +47,68 @@ export interface SidenavProps { * * A custom sidenav component that provides a sidebar with options for navigation and interaction. * - * @property {SidenavHeader} header - * - Determines whether to display the header section of the sidenav. - * - * @property {ReactNode} primaryAction - * - The primary action displayed at the top of the sidenav. - * - * @property {SuiteLauncher} suiteLauncher - * - The suite launcher displayed at the top of the sidenav. - * - * @property {ReactNode} menu - * - The menu displayed at the top of the sidenav. - * - * @property {ReactNode} collapsedPrimaryAction - * - The primary action displayed when the sidenav is collapsed. - * - * @property {SidenavOption[]} options - * - An array of options to be displayed in the sidenav. - * - * @property {number} activeOptionId - * - The ID of the currently active option in the sidenav. - * - * @property {boolean} showSubsections - * - Determines whether to display the subsections of the sidenav. - * - * @property {boolean} isCollapsed - * - Determines whether the sidenav is collapsed or not. - * - * @property {SidenavStorage} storage - * - The storage information displayed at the bottom of the sidenav. - * - * @property {(optionId: number, isSubsection: boolean) => void} onOptionClick - * - A callback function triggered when an option in the sidenav is clicked. - * - * @property {() => void} onToggleCollapse - * - A callback function triggered when the collapse button is clicked. + * @property {SidenavHeader} header - Header configuration with logo, title, and onClick handler + * @property {ReactNode} primaryAction - The primary action displayed at the top of the sidenav + * @property {object} suiteLauncher - The suite launcher configuration + * @property {ReactNode} collapsedPrimaryAction - The primary action displayed when the sidenav is collapsed + * @property {SidenavOption[]} options - An array of options to be displayed in the sidenav + * @property {boolean} showSubsections - Determines whether to display the subsections of the sidenav + * @property {boolean} isCollapsed - Determines whether the sidenav is collapsed or not + * @property {SidenavStorage} storage - The storage information displayed at the bottom of the sidenav + * @property {() => void} onToggleCollapse - A callback function triggered when the collapse button is clicked + * @property {ComponentType} LinkComponent - Optional Link component for navigation (e.g., React Router's NavLink or Link) */ -export const Sidenav = ({ +const Sidenav = ({ header, primaryAction, suiteLauncher, collapsedPrimaryAction, options, - activeOptionId, showSubsections, isCollapsed = false, storage, - onOptionClick, onToggleCollapse, -}: SidenavProps) => ( -
- {onToggleCollapse && ( - - )} -
-
- - {!isCollapsed && suiteLauncher && ( - - )} -
-
- {isCollapsed ? collapsedPrimaryAction : primaryAction} -
- {options.map((option) => { - if (option.subsection && !showSubsections) { - return null; - } + LinkComponent, +}: SidenavProps) => { + return ( +
+
+ - if (isCollapsed && option.subsection) { - return null; - } - - const isActive = activeOptionId === option.id; - - return ( - - ); - })} -
-
-
- {!isCollapsed && storage && ( -
-
-
-

{storage.used}

-

/

-

{storage.total}

-
- {storage.upgradeLabel && ( - - )} -
-
-
+ {isCollapsed ? collapsedPrimaryAction : primaryAction} +
- )} -
-); + + {!isCollapsed && storage && ( + + )} +
+ ); +}; + +export default Sidenav; diff --git a/src/components/sidenav/SidenavHeader.tsx b/src/components/sidenav/SidenavHeader.tsx new file mode 100644 index 0000000..b00f26f --- /dev/null +++ b/src/components/sidenav/SidenavHeader.tsx @@ -0,0 +1,85 @@ +import { SidebarIcon } from '@phosphor-icons/react'; +import { SuiteLauncher } from '../suiteLauncher'; + +interface SidenavHeaderProps { + logo: string; + title: string; + onClick: () => void; + isCollapsed: boolean; + className?: string; + onToggleCollapse?: () => void; + suiteLauncher?: { + className?: string; + suiteArray: { + icon: JSX.Element; + title: string; + onClick: () => void; + isMain?: boolean; + availableSoon?: boolean; + isLocked?: boolean; + }[]; + soonText: string; + }; +} + +const SidenavHeader = ({ + logo, + title, + onClick, + isCollapsed, + className, + onToggleCollapse, + suiteLauncher, +}: SidenavHeaderProps): JSX.Element => { + return ( +
+ {isCollapsed ? ( +
+ + )} + +
+ ) : ( + <> + +
+ {suiteLauncher && ( + + )} + {onToggleCollapse && ( + + )} +
+ + )} +
+ ); +}; + +export default SidenavHeader; diff --git a/src/components/sidenav/SidenavItem.tsx b/src/components/sidenav/SidenavItem.tsx new file mode 100644 index 0000000..2997d32 --- /dev/null +++ b/src/components/sidenav/SidenavItem.tsx @@ -0,0 +1,67 @@ +import { IconProps } from '@phosphor-icons/react'; +import { ComponentType, ReactNode } from 'react'; + +interface SidenavItemProps { + label: string; + notifications?: number; + to?: string; + Icon: React.ForwardRefExoticComponent>; + onClick?: () => void; + iconDataCy?: string; + isActive?: boolean; + isCollapsed?: boolean; + subsection?: boolean; + LinkComponent?: ComponentType<{ to: string; className?: string; children: ReactNode }>; +} + +const SidenavItem = ({ + label, + to, + Icon, + onClick, + notifications, + iconDataCy, + isActive = false, + isCollapsed = false, + subsection = false, + LinkComponent, +}: SidenavItemProps): JSX.Element => { + const content: ReactNode = ( +
+
+ + {!isCollapsed &&

{label}

} +
+ {!isCollapsed && notifications && ( +
+

{notifications}

+
+ )} +
+ ); + + const handleClick = onClick || (() => undefined); + + return ( + + ); +}; + +export default SidenavItem; diff --git a/src/components/sidenav/SidenavOptions.tsx b/src/components/sidenav/SidenavOptions.tsx new file mode 100644 index 0000000..f235a9f --- /dev/null +++ b/src/components/sidenav/SidenavOptions.tsx @@ -0,0 +1,63 @@ +import { ComponentType, ReactNode } from 'react'; +import { Icon } from '@phosphor-icons/react'; +import SidenavItem from './SidenavItem'; + +export interface SidenavOption { + label: string; + icon: Icon; + iconDataCy: string; + isVisible: boolean; + to?: string; + isActive?: boolean; + notifications?: number; + onClick?: () => void; + subsection?: boolean; +} + +interface SidenavOptionsProps { + options: SidenavOption[]; + isCollapsed: boolean; + showSubsections?: boolean; + LinkComponent?: ComponentType<{ to: string; className?: string; children: ReactNode }>; +} + +const SidenavOptions = ({ + options, + isCollapsed, + showSubsections, + LinkComponent, +}: SidenavOptionsProps): JSX.Element => { + return ( +
+ {options + .filter((option) => option.isVisible) + .map((option, index) => { + if (option.subsection && !showSubsections) { + return null; + } + + if (isCollapsed && option.subsection) { + return null; + } + + return ( + + ); + })} +
+ ); +}; + +export default SidenavOptions; diff --git a/src/components/sidenav/SidenavStorage.tsx b/src/components/sidenav/SidenavStorage.tsx new file mode 100644 index 0000000..9c3e90f --- /dev/null +++ b/src/components/sidenav/SidenavStorage.tsx @@ -0,0 +1,42 @@ +interface SidenavStorageProps { + usage: string; + limit: string; + percentage: number; + onUpgradeClick: () => void; + upgradeLabel?: string; +} + +const SidenavStorage = ({ + usage, + limit, + percentage, + onUpgradeClick, + upgradeLabel, +}: SidenavStorageProps): JSX.Element => { + return ( +
+
+
+

{usage}

+

/

+

{limit}

+
+ {upgradeLabel && ( + + )} +
+
+
+
+
+ ); +}; + +export default SidenavStorage; diff --git a/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap b/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap deleted file mode 100644 index 76db4a4..0000000 --- a/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap +++ /dev/null @@ -1,620 +0,0 @@ -// 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 -

-
- -
-
-
-
-
-
-
-`; diff --git a/src/components/sidenav/index.ts b/src/components/sidenav/index.ts new file mode 100644 index 0000000..47479fc --- /dev/null +++ b/src/components/sidenav/index.ts @@ -0,0 +1,4 @@ +export { default as Sidenav } from './Sidenav'; +export { default as SidenavItem } from './SidenavItem'; +export type { SidenavOption } from './SidenavOptions'; +export type { SidenavHeader, SidenavProps, SidenavStorage } from './Sidenav'; diff --git a/src/components/suiteLauncher/__test__/__snapshots__/SuiteLauncher.test.tsx.snap b/src/components/suiteLauncher/__test__/__snapshots__/SuiteLauncher.test.tsx.snap index ddd6c33..c62320f 100644 --- a/src/components/suiteLauncher/__test__/__snapshots__/SuiteLauncher.test.tsx.snap +++ b/src/components/suiteLauncher/__test__/__snapshots__/SuiteLauncher.test.tsx.snap @@ -15,7 +15,7 @@ exports[`SuiteLauncher > should match snapshot 1`] = ` data-testid="popover-button" >
should match snapshot 1`] = ` data-testid="popover-button" >
= { }, tags: ['autodocs'], argTypes: { - onOptionClick: { action: 'optionClicked' }, onToggleCollapse: { action: 'toggleCollapse' }, }, decorators: [ @@ -68,65 +67,78 @@ type Story = StoryObj; const MAIL_OPTIONS = [ { - id: 0, - title: 'Inbox', + label: 'Inbox', icon: TrayIcon, + iconDataCy: 'sidenav-inbox', + isVisible: true, notifications: 12, }, { - id: 1, - title: 'Sent', + label: 'Sent', icon: PaperPlaneTiltIcon, + iconDataCy: 'sidenav-sent', + isVisible: true, }, { - id: 2, - title: 'Drafts', + label: 'Drafts', icon: FileIcon, + iconDataCy: 'sidenav-drafts', + isVisible: true, notifications: 3, }, { - id: 3, - title: 'Trash', + label: 'Trash', icon: TrashIcon, + iconDataCy: 'sidenav-trash', + isVisible: true, }, { - id: 4, - title: 'Labels', + label: 'Labels', icon: CaretDownIcon, + iconDataCy: 'sidenav-labels', + isVisible: true, }, { - id: 5, - title: 'Important', + label: 'Important', icon: StarIcon, + iconDataCy: 'sidenav-important', + isVisible: true, subsection: true, }, { - id: 6, - title: 'Work', + label: 'Work', icon: TagIcon, + iconDataCy: 'sidenav-work', + isVisible: true, subsection: true, }, { - id: 7, - title: 'Personal', + label: 'Personal', icon: TagIcon, + iconDataCy: 'sidenav-personal', + isVisible: true, subsection: true, }, ]; const InteractiveSidenav = (args: SidenavProps) => { - const [{ activeOptionId, showSubsections, isCollapsed }, setArgs] = useArgs(); + const [{ showSubsections, isCollapsed, options }, setArgs] = useArgs(); + + const handleOptionClick = (optionIndex: number, isSubsection: boolean) => { + const updatedOptions = options.map((opt: any, idx: number) => ({ + ...opt, + isActive: idx === optionIndex, + })); - const handleOptionClick = (optionId: number, isSubsection: boolean) => { if (isSubsection) { - setArgs({ activeOptionId: optionId }); + setArgs({ options: updatedOptions }); return; } - if (optionId === 4) { - setArgs({ activeOptionId: optionId, showSubsections: !showSubsections }); + if (optionIndex === 4) { + setArgs({ options: updatedOptions, showSubsections: !showSubsections }); } else { - setArgs({ activeOptionId: optionId, showSubsections: false }); + setArgs({ options: updatedOptions, showSubsections: false }); } }; @@ -136,13 +148,17 @@ const InteractiveSidenav = (args: SidenavProps) => { } : undefined; + const optionsWithHandlers = options.map((option: any, index: number) => ({ + ...option, + onClick: () => handleOptionClick(index, !!option.subsection), + })); + return ( ); @@ -171,13 +187,12 @@ export const Default: Story = { suiteArray: SUITE_ARRAY, soonText: 'Soon', }, - options: MAIL_OPTIONS, - activeOptionId: 0, + options: MAIL_OPTIONS.map((opt, idx) => ({ ...opt, isActive: idx === 0 })), showSubsections: false, isCollapsed: false, storage: { - used: '2.8 GB', - total: '4 GB', + usage: '2.8 GB', + limit: '4 GB', percentage: 70, upgradeLabel: 'Upgrade', onUpgradeClick: () => console.log('Upgrade clicked'), @@ -198,7 +213,7 @@ export const WithSubsectionsExpanded: Story = { render: InteractiveSidenav, args: { ...Default.args, - activeOptionId: 4, + options: MAIL_OPTIONS.map((opt, idx) => ({ ...opt, isActive: idx === 4 })), showSubsections: true, }, }; @@ -225,8 +240,8 @@ export const HighStorageUsage: Story = { args: { ...Default.args, storage: { - used: '9.5 GB', - total: '10 GB', + usage: '9.5 GB', + limit: '10 GB', percentage: 95, upgradeLabel: 'Upgrade now', onUpgradeClick: () => console.log('Upgrade clicked'), @@ -243,10 +258,21 @@ export const Minimal: Story = { onClick: () => console.log('Header clicked'), }, options: [ - { id: 0, title: 'All files', icon: FileIcon }, - { id: 1, title: 'Trash', icon: TrashIcon }, + { + label: 'All files', + icon: FileIcon, + iconDataCy: 'sidenav-all-files', + isVisible: true, + isActive: true, + }, + { + label: 'Trash', + icon: TrashIcon, + iconDataCy: 'sidenav-trash-minimal', + isVisible: true, + isActive: false, + }, ], - activeOptionId: 0, showSubsections: false, isCollapsed: false, onToggleCollapse: () => {}, From e6db176541f066b4212834731592bab25c18effd Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:50:39 +0100 Subject: [PATCH 14/15] fix: tests --- src/components/sidenav/Sidenav.tsx | 2 + src/components/sidenav/SidenavHeader.tsx | 2 +- src/components/sidenav/SidenavStorage.tsx | 18 +- .../sidenav/__test__/Sidenav.test.tsx | 118 ++-- .../__snapshots__/Sidenav.test.tsx.snap | 608 ++++++++++++++++++ .../components/sidenav/Sidenav.stories.tsx | 19 + 6 files changed, 721 insertions(+), 46 deletions(-) create mode 100644 src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx index 31daf53..99d6b90 100644 --- a/src/components/sidenav/Sidenav.tsx +++ b/src/components/sidenav/Sidenav.tsx @@ -16,6 +16,7 @@ export interface SidenavStorage { percentage: number; onUpgradeClick: () => void; upgradeLabel?: string; + isLoading?: boolean; } export interface SidenavProps { @@ -105,6 +106,7 @@ const Sidenav = ({ percentage={storage.percentage} onUpgradeClick={storage.onUpgradeClick} upgradeLabel={storage.upgradeLabel} + isLoading={storage.isLoading} /> )}
diff --git a/src/components/sidenav/SidenavHeader.tsx b/src/components/sidenav/SidenavHeader.tsx index b00f26f..b988524 100644 --- a/src/components/sidenav/SidenavHeader.tsx +++ b/src/components/sidenav/SidenavHeader.tsx @@ -33,7 +33,7 @@ const SidenavHeader = ({ }: SidenavHeaderProps): JSX.Element => { return (
{isCollapsed ? (
diff --git a/src/components/sidenav/SidenavStorage.tsx b/src/components/sidenav/SidenavStorage.tsx index 9c3e90f..94a8c00 100644 --- a/src/components/sidenav/SidenavStorage.tsx +++ b/src/components/sidenav/SidenavStorage.tsx @@ -4,6 +4,7 @@ interface SidenavStorageProps { percentage: number; onUpgradeClick: () => void; upgradeLabel?: string; + isLoading?: boolean; } const SidenavStorage = ({ @@ -12,14 +13,25 @@ const SidenavStorage = ({ percentage, onUpgradeClick, upgradeLabel, + isLoading = true, }: SidenavStorageProps): JSX.Element => { return (
-

{usage}

-

/

-

{limit}

+ {isLoading ? ( +
+
+
+
+
+ ) : ( + <> +

{usage}

+

/

+

{limit}

+ + )}
{upgradeLabel && ( +
+
+
+
+ + + + +
+
+
+
+
+`; + +exports[`Sidenav Component > should match snapshot when collapsed 1`] = ` +
+
+
+
+
+ + +
+
+
+
+ + + + +
+
+
+
+
+`; + +exports[`Sidenav Component > should match snapshot with primary action 1`] = ` +
+
+
+
+ +
+
+
+ +
+ + + + +
+
+
+
+
+`; + +exports[`Sidenav Component > should match snapshot with storage 1`] = ` +
+
+
+
+ +
+
+
+
+ + + + +
+
+
+
+
+
+

+ 2.8 GB +

+

+ / +

+

+ 4 GB +

+
+ +
+
+
+
+
+
+
+`; diff --git a/src/stories/components/sidenav/Sidenav.stories.tsx b/src/stories/components/sidenav/Sidenav.stories.tsx index 1fa8456..2899ce1 100644 --- a/src/stories/components/sidenav/Sidenav.stories.tsx +++ b/src/stories/components/sidenav/Sidenav.stories.tsx @@ -135,6 +135,7 @@ const InteractiveSidenav = (args: SidenavProps) => { return; } + // If clicking "Labels" option (index 4), toggle subsections if (optionIndex === 4) { setArgs({ options: updatedOptions, showSubsections: !showSubsections }); } else { @@ -145,6 +146,7 @@ const InteractiveSidenav = (args: SidenavProps) => { const handleToggleCollapse = args.onToggleCollapse ? () => { setArgs({ isCollapsed: !isCollapsed }); + args.onToggleCollapse?.(); } : undefined; @@ -196,6 +198,7 @@ export const Default: Story = { percentage: 70, upgradeLabel: 'Upgrade', onUpgradeClick: () => console.log('Upgrade clicked'), + isLoading: false, }, onToggleCollapse: () => {}, }, @@ -245,6 +248,7 @@ export const HighStorageUsage: Story = { percentage: 95, upgradeLabel: 'Upgrade now', onUpgradeClick: () => console.log('Upgrade clicked'), + isLoading: false, }, }, }; @@ -286,3 +290,18 @@ export const WithoutCollapseButton: Story = { onToggleCollapse: undefined, }, }; + +export const LoadingStorage: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + storage: { + usage: '0 GB', + limit: '0 GB', + percentage: 0, + upgradeLabel: 'Upgrade', + onUpgradeClick: () => console.log('Upgrade clicked'), + isLoading: true, + }, + }, +}; From 8876703cc67305f92f80318b5e2625afdb7b90a2 Mon Sep 17 00:00:00 2001 From: Xavi Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:09:29 +0100 Subject: [PATCH 15/15] fix: remove useless code and fix some styles --- src/components/sidenav/Sidenav.tsx | 14 +-- src/components/sidenav/SidenavItem.tsx | 47 +++----- src/components/sidenav/SidenavOptions.tsx | 12 +- .../sidenav/__test__/Sidenav.test.tsx | 29 +++-- .../__snapshots__/Sidenav.test.tsx.snap | 104 +++++++++++------- 5 files changed, 102 insertions(+), 104 deletions(-) diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx index 99d6b90..a6e9bd2 100644 --- a/src/components/sidenav/Sidenav.tsx +++ b/src/components/sidenav/Sidenav.tsx @@ -1,4 +1,4 @@ -import { ComponentType, ReactNode } from 'react'; +import { ReactNode } from 'react'; import SidenavOptions, { SidenavOption } from './SidenavOptions'; import SidenavHeader from './SidenavHeader'; import SidenavStorage from './SidenavStorage'; @@ -40,7 +40,6 @@ export interface SidenavProps { isCollapsed?: boolean; storage?: SidenavStorage; onToggleCollapse?: () => void; - LinkComponent?: ComponentType<{ to: string; className?: string; children: ReactNode }>; } /** @@ -52,12 +51,11 @@ export interface SidenavProps { * @property {ReactNode} primaryAction - The primary action displayed at the top of the sidenav * @property {object} suiteLauncher - The suite launcher configuration * @property {ReactNode} collapsedPrimaryAction - The primary action displayed when the sidenav is collapsed - * @property {SidenavOption[]} options - An array of options to be displayed in the sidenav + * @property {SidenavOption[]} options - An array of options to be displayed in the sidenav. Each option can specify an 'as' prop to use a custom component (e.g., NavLink) * @property {boolean} showSubsections - Determines whether to display the subsections of the sidenav * @property {boolean} isCollapsed - Determines whether the sidenav is collapsed or not * @property {SidenavStorage} storage - The storage information displayed at the bottom of the sidenav * @property {() => void} onToggleCollapse - A callback function triggered when the collapse button is clicked - * @property {ComponentType} LinkComponent - Optional Link component for navigation (e.g., React Router's NavLink or Link) */ const Sidenav = ({ header, @@ -69,7 +67,6 @@ const Sidenav = ({ isCollapsed = false, storage, onToggleCollapse, - LinkComponent, }: SidenavProps) => { return (
{isCollapsed ? collapsedPrimaryAction : primaryAction} - +
diff --git a/src/components/sidenav/SidenavItem.tsx b/src/components/sidenav/SidenavItem.tsx index 2997d32..0e380f6 100644 --- a/src/components/sidenav/SidenavItem.tsx +++ b/src/components/sidenav/SidenavItem.tsx @@ -1,22 +1,18 @@ import { IconProps } from '@phosphor-icons/react'; -import { ComponentType, ReactNode } from 'react'; interface SidenavItemProps { label: string; notifications?: number; - to?: string; Icon: React.ForwardRefExoticComponent>; onClick?: () => void; iconDataCy?: string; isActive?: boolean; isCollapsed?: boolean; subsection?: boolean; - LinkComponent?: ComponentType<{ to: string; className?: string; children: ReactNode }>; } const SidenavItem = ({ label, - to, Icon, onClick, notifications, @@ -24,42 +20,31 @@ const SidenavItem = ({ isActive = false, isCollapsed = false, subsection = false, - LinkComponent, }: SidenavItemProps): JSX.Element => { - const content: ReactNode = ( -
-
- - {!isCollapsed &&

{label}

} -
- {!isCollapsed && notifications && ( -
-

{notifications}

-
- )} -
- ); - - const handleClick = onClick || (() => undefined); - return ( ); }; diff --git a/src/components/sidenav/SidenavOptions.tsx b/src/components/sidenav/SidenavOptions.tsx index f235a9f..5296487 100644 --- a/src/components/sidenav/SidenavOptions.tsx +++ b/src/components/sidenav/SidenavOptions.tsx @@ -1,4 +1,3 @@ -import { ComponentType, ReactNode } from 'react'; import { Icon } from '@phosphor-icons/react'; import SidenavItem from './SidenavItem'; @@ -7,7 +6,6 @@ export interface SidenavOption { icon: Icon; iconDataCy: string; isVisible: boolean; - to?: string; isActive?: boolean; notifications?: number; onClick?: () => void; @@ -18,15 +16,9 @@ interface SidenavOptionsProps { options: SidenavOption[]; isCollapsed: boolean; showSubsections?: boolean; - LinkComponent?: ComponentType<{ to: string; className?: string; children: ReactNode }>; } -const SidenavOptions = ({ - options, - isCollapsed, - showSubsections, - LinkComponent, -}: SidenavOptionsProps): JSX.Element => { +const SidenavOptions = ({ options, isCollapsed, showSubsections }: SidenavOptionsProps): JSX.Element => { return (
{options @@ -49,10 +41,8 @@ const SidenavOptions = ({ isActive={option.isActive} notifications={option.notifications} onClick={option.onClick} - to={option.to} isCollapsed={isCollapsed} subsection={option.subsection} - LinkComponent={LinkComponent} /> ); })} diff --git a/src/components/sidenav/__test__/Sidenav.test.tsx b/src/components/sidenav/__test__/Sidenav.test.tsx index 04ef085..9d90136 100644 --- a/src/components/sidenav/__test__/Sidenav.test.tsx +++ b/src/components/sidenav/__test__/Sidenav.test.tsx @@ -5,9 +5,9 @@ import '@testing-library/jest-dom'; import Sidenav, { SidenavProps } from '../Sidenav'; import { SidenavOption } from '../SidenavOptions'; -const MockIcon = React.forwardRef( - ({ size = 20 }, ref) => , -); +const MockIcon = React.forwardRef(({ size = 20 }, ref) => ( + +)); describe('Sidenav Component', () => { const onHeaderClick = vi.fn(); @@ -18,7 +18,14 @@ describe('Sidenav Component', () => { const mockOptions: SidenavOption[] = [ { label: 'Inbox', icon: MockIcon, iconDataCy: 'inbox', isVisible: true, notifications: 5, onClick: onOptionClick }, { label: 'Sent', icon: MockIcon, iconDataCy: 'sent', isVisible: true, onClick: onOptionClick }, - { label: 'Drafts', icon: MockIcon, iconDataCy: 'drafts', isVisible: true, notifications: 2, onClick: onOptionClick }, + { + label: 'Drafts', + icon: MockIcon, + iconDataCy: 'drafts', + isVisible: true, + notifications: 2, + onClick: onOptionClick, + }, { label: 'Labels', icon: MockIcon, iconDataCy: 'labels', isVisible: true, onClick: onOptionClick }, { label: 'Important', @@ -180,10 +187,10 @@ describe('Sidenav Component', () => { isLoading: true, }, }); - // Should show skeleton loaders instead of text + expect(queryByText('2.8 GB')).not.toBeInTheDocument(); expect(queryByText('4 GB')).not.toBeInTheDocument(); - // Should have loading skeletons (animated pulse divs) + const skeletons = container.querySelectorAll('.animate-pulse'); expect(skeletons.length).toBeGreaterThan(0); }); @@ -250,22 +257,22 @@ describe('Sidenav Component', () => { describe('Collapse toggle button', () => { it('does not render collapse button when onToggleCollapse is not provided', () => { const { container } = renderSidenav(); - // The collapse button is only visible on hover and in certain states + const buttons = container.querySelectorAll('button'); - // Should only have header button + option buttons - expect(buttons.length).toBe(5); // 1 header + 4 visible options + + expect(buttons.length).toBe(5); }); it('renders collapse button when onToggleCollapse is provided', () => { const { container } = renderSidenav({ onToggleCollapse }); const buttons = container.querySelectorAll('button'); - // Should have header button + collapse button + option buttons + expect(buttons.length).toBeGreaterThan(5); }); it('calls onToggleCollapse when collapse button is clicked', () => { const { container } = renderSidenav({ onToggleCollapse }); - // Find the collapse button (contains SidebarIcon) + const collapseButton = Array.from(container.querySelectorAll('button')).find( (btn) => btn.querySelector('svg') && !btn.querySelector('img'), ); diff --git a/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap b/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap index 29b9588..8369c0c 100644 --- a/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap +++ b/src/components/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap @@ -43,22 +43,24 @@ exports[`Sidenav Component > should match snapshot 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Inbox

5

@@ -73,14 +75,16 @@ exports[`Sidenav Component > should match snapshot 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Sent

@@ -94,22 +98,24 @@ exports[`Sidenav Component > should match snapshot 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Drafts

2

@@ -124,14 +130,16 @@ exports[`Sidenav Component > should match snapshot 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Labels

@@ -200,7 +208,7 @@ exports[`Sidenav Component > should match snapshot when collapsed 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-center" >
should match snapshot when collapsed 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-center" >
should match snapshot when collapsed 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-center" >
should match snapshot when collapsed 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-center" >
should match snapshot with primary action 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Inbox

5

@@ -352,14 +362,16 @@ exports[`Sidenav Component > should match snapshot with primary action 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Sent

@@ -373,22 +385,24 @@ exports[`Sidenav Component > should match snapshot with primary action 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Drafts

2

@@ -403,14 +417,16 @@ exports[`Sidenav Component > should match snapshot with primary action 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Labels

@@ -466,22 +482,24 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Inbox

5

@@ -496,14 +514,16 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Sent

@@ -517,22 +537,24 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Drafts

2

@@ -547,14 +569,16 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = ` class="flex flex-row px-2.5 py-2 w-full items-center justify-between" >
-

+

Labels