Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/components/sidenav/Sidenav.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`relative flex flex-col p-2 h-full justify-between bg-gray-1 border-r border-gray-10 transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-64'
}`}
>
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="absolute top-1/2 -right-3 -translate-y-1/2 flex items-center justify-center w-6 h-6 rounded-full bg-surface border border-gray-10 text-gray-50 hover:text-gray-70 hover:bg-gray-5 shadow-sm z-10"
>
{isCollapsed ? <CaretRightIcon size={14} /> : <CaretLeftIcon size={14} />}
</button>
)}
<div className="flex flex-col">
<div className={`flex flex-row justify-between w-full p-5 ${isCollapsed ? 'px-2 justify-center' : ''}`}>
<div className="flex flex-row gap-2 items-center">
<img src={header.logo} width={28} alt={header.title} />
{!isCollapsed && <p className="text-xl font-medium text-gray-100">{header.title}</p>}
</div>
{!isCollapsed && (
<button onClick={onMenuClick}>
<DotsNineIcon size={28} className="text-gray-50 active:text-gray-70" />
</button>
)}
</div>
<div className="flex flex-col gap-4">
{isCollapsed ? collapsedPrimaryAction : primaryAction}
<div className="flex flex-col w-full">
{options.map((option) => {
if (option.subsection && !showSubsections) {
return null;
}

if (isCollapsed && option.subsection) {
return null;
}

const isActive = activeOptionId === option.id;

return (
<button
key={option.id}
className={`flex w-full flex-col focus-visible:bg-gray-10 rounded-lg ${
isActive ? 'bg-primary/20' : 'hover:bg-gray-5'
} ${option.subsection ? 'pl-5' : ''}`}
onClick={() => onOptionClick(option.id, !!option.subsection)}
title={isCollapsed ? option.title : undefined}
>
<div
className={`flex flex-row px-2.5 py-2 w-full items-center ${isCollapsed ? 'justify-center' : 'justify-between'}`}
>
<div className={`flex flex-row gap-3 items-center ${isActive ? 'text-primary' : 'text-gray-60'}`}>
<option.icon size={option.iconSize ?? 20} weight={option.weight ?? 'regular'} />
{!isCollapsed && <p>{option.title}</p>}
</div>
{!isCollapsed && option.notifications && (
<div className={`flex rounded-full px-1.5 py-0.5 ${isActive ? 'bg-primary' : 'bg-gray-40'}`}>
<p className="text-white text-xs font-medium">{option.notifications}</p>
</div>
)}
</div>
</button>
);
})}
</div>
</div>
</div>
{!isCollapsed && storage && (
<div className="flex flex-col w-full gap-2.5">
<div className="flex flex-row w-full justify-between">
<div className="flex flex-row items-center gap-2">
<p className="text-gray-60 text-sm font-semibold">{storage.used}</p>
<p className="text-gray-60 text-sm">/</p>
<p className="text-gray-60 text-sm">{storage.total}</p>
</div>
{storage.upgradeLabel && (
<button
className="text-primary text-sm hover:text-primary-dark font-semibold"
onClick={storage.onUpgradeClick}
>
{storage.upgradeLabel}
</button>
)}
</div>
<div className="flex w-full h-1.5 bg-gray-10 rounded-full">
<div
className="bg-gray-60 rounded-full"
style={{
width: `${storage.percentage}%`,
}}
/>
</div>
</div>
)}
</div>
);
};
243 changes: 243 additions & 0 deletions src/components/sidenav/__test__/Sidenav.test.tsx
Original file line number Diff line number Diff line change
@@ -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<SVGSVGElement, { size?: number | string; weight?: string }>(
({ size = 20 }, ref) => <svg ref={ref} data-testid="mock-icon" width={size} height={size} />,
);

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<SidenavProps> = {}) =>
render(<Sidenav {...defaultProps} {...props} />);

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: <button data-testid="primary-action">New message</button>,
});
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: <button data-testid="primary-action">New message</button>,
});
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: <button data-testid="primary-action">New message</button>,
collapsedPrimaryAction: <button data-testid="collapsed-primary-action">+</button>,
});
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();
});
});
});
Loading