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 && (
+
+ )}
+
+
+
+

+ {!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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Sidenav Component > should match snapshot when collapsed 1`] = `
+
+
+
+
+
+
+

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

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

+
+ 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,
+ },
+};