Skip to content
Merged
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
11 changes: 11 additions & 0 deletions src/components/shell/AppShellLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { AppSidebar } from './AppSidebar';

export function AppShellLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen bg-[#050505]">
<AppSidebar />
<main className="min-w-0 flex-1">{children}</main>
</div>
);
}
33 changes: 33 additions & 0 deletions src/components/shell/AppSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AppSidebar />);
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(<AppSidebar />);
expect(screen.getByRole('link', { name: 'Analytics' })).toHaveAttribute('aria-current', 'page');
});
});
72 changes: 72 additions & 0 deletions src/components/shell/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside
aria-label="Application navigation"
className={`hidden min-h-screen shrink-0 border-r border-white/10 bg-[#0a0a0b] transition-[width] md:block ${
collapsed ? 'w-16' : 'w-64'
}`}
>
<div className="sticky top-0 flex min-h-screen flex-col p-3">
<button
type="button"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-expanded={!collapsed}
onClick={() => setCollapsed((value) => !value)}
className="mb-5 flex h-10 items-center justify-center rounded-lg text-white/70 hover:bg-white/10 hover:text-white"
>
{collapsed ? <FiMenu aria-hidden="true" /> : <FiChevronLeft aria-hidden="true" />}
</button>

<nav aria-label="Primary" className="space-y-2">
{navigation.map(({ href, label, icon: Icon }) => {
const active = pathname === href || (href !== '/' && pathname.startsWith(`${href}/`));
return (
<Link
key={href}
href={href}
aria-current={active ? 'page' : undefined}
aria-label={collapsed ? label : undefined}
title={collapsed ? label : undefined}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm ${
active ? 'bg-cyan-400/15 text-cyan-300' : 'text-white/70 hover:bg-white/10'
}`}
>
<Icon aria-hidden="true" />
{!collapsed && <span>{label}</span>}
</Link>
);
})}
</nav>
</div>
</aside>
);
}
2 changes: 2 additions & 0 deletions src/components/shell/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AppShellLayout } from './AppShellLayout';
export { AppSidebar } from './AppSidebar';
Loading