diff --git a/src/components/shell/AppShellLayout.tsx b/src/components/shell/AppShellLayout.tsx new file mode 100644 index 00000000..0e947e4e --- /dev/null +++ b/src/components/shell/AppShellLayout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react'; +import { AppSidebar } from './AppSidebar'; + +export function AppShellLayout({ children }: { children: ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/components/shell/AppSidebar.test.tsx b/src/components/shell/AppSidebar.test.tsx new file mode 100644 index 00000000..2d8fed90 --- /dev/null +++ b/src/components/shell/AppSidebar.test.tsx @@ -0,0 +1,33 @@ +/** @vitest-environment happy-dom */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { AppSidebar } from './AppSidebar'; + +vi.mock('next/navigation', () => ({ usePathname: () => '/analytics' })); + +describe('AppSidebar', () => { + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it('persists collapsed state and keeps labels accessible', () => { + render(); + const toggle = screen.getByRole('button', { name: 'Collapse sidebar' }); + + fireEvent.click(toggle); + + expect(screen.getByRole('button', { name: 'Expand sidebar' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + expect(screen.getByRole('link', { name: 'Analytics' })).toHaveAttribute('title', 'Analytics'); + expect(localStorage.getItem('commitlabs:sidebar-collapsed')).toBe('true'); + }); + + it('marks the active route', () => { + render(); + expect(screen.getByRole('link', { name: 'Analytics' })).toHaveAttribute('aria-current', 'page'); + }); +}); diff --git a/src/components/shell/AppSidebar.tsx b/src/components/shell/AppSidebar.tsx new file mode 100644 index 00000000..353f75ca --- /dev/null +++ b/src/components/shell/AppSidebar.tsx @@ -0,0 +1,72 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { FiBarChart2, FiChevronLeft, FiHome, FiMenu, FiPlus, FiSettings } from 'react-icons/fi'; + +const STORAGE_KEY = 'commitlabs:sidebar-collapsed'; + +const navigation = [ + { href: '/', label: 'Overview', icon: FiHome }, + { href: '/analytics', label: 'Analytics', icon: FiBarChart2 }, + { href: '/create', label: 'Create commitment', icon: FiPlus }, + { href: '/settings', label: 'Settings', icon: FiSettings }, +] as const; + +export function AppSidebar() { + const pathname = usePathname(); + const [collapsed, setCollapsed] = useState(false); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setCollapsed(window.localStorage.getItem(STORAGE_KEY) === 'true'); + setHydrated(true); + }, []); + + useEffect(() => { + if (hydrated) window.localStorage.setItem(STORAGE_KEY, String(collapsed)); + }, [collapsed, hydrated]); + + return ( + + ); +} diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts new file mode 100644 index 00000000..184cee39 --- /dev/null +++ b/src/components/shell/index.ts @@ -0,0 +1,2 @@ +export { AppShellLayout } from './AppShellLayout'; +export { AppSidebar } from './AppSidebar';