diff --git a/src/components/sidenav/Sidenav.tsx b/src/components/sidenav/Sidenav.tsx new file mode 100644 index 0000000..e31f151 --- /dev/null +++ b/src/components/sidenav/Sidenav.tsx @@ -0,0 +1,151 @@ +import { Icon, IconWeight, DotsNineIcon, CaretLeftIcon, CaretRightIcon } 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; + 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} + {!isCollapsed &&

{header.title}

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

{storage.used}

+

/

+

{storage.total}

+
+ {storage.upgradeLabel && ( + + )} +
+
+
+
+
+ )} +
+ ); +}; 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 +

+
+ +
+
+
+
+
+
+
+`; diff --git a/src/stories/components/sidenav/Sidenav.stories.tsx b/src/stories/components/sidenav/Sidenav.stories.tsx new file mode 100644 index 0000000..73e2d8c --- /dev/null +++ b/src/stories/components/sidenav/Sidenav.stories.tsx @@ -0,0 +1,227 @@ +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' }, + onToggleCollapse: { action: 'toggleCollapse' }, + }, + 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, isCollapsed }, 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 }); + } + }; + + const handleToggleCollapse = args.onToggleCollapse + ? () => { + setArgs({ isCollapsed: !isCollapsed }); + } + : undefined; + + return ( + + ); +}; + +export const Default: Story = { + render: InteractiveSidenav, + args: { + header: { + logo: 'https://internxt.com/favicon.ico', + title: 'Mail', + }, + primaryAction: ( + + ), + collapsedPrimaryAction: ( + + ), + options: MAIL_OPTIONS, + activeOptionId: 0, + showSubsections: false, + isCollapsed: false, + storage: { + used: '2.8 GB', + total: '4 GB', + percentage: 70, + upgradeLabel: 'Upgrade', + onUpgradeClick: () => console.log('Upgrade clicked'), + }, + onToggleCollapse: () => {}, + }, +}; + +export const Collapsed: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + isCollapsed: true, + }, +}; + +export const WithSubsectionsExpanded: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + activeOptionId: 4, + showSubsections: true, + }, +}; + +export const WithoutPrimaryAction: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + primaryAction: undefined, + collapsedPrimaryAction: 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, + isCollapsed: false, + onToggleCollapse: () => {}, + }, +}; + +export const WithoutCollapseButton: Story = { + render: InteractiveSidenav, + args: { + ...Default.args, + onToggleCollapse: undefined, + }, +};