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
77 changes: 40 additions & 37 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppShell } from './components/AppShell/AppShell';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthGuard } from './components/AuthGuard/AuthGuard';

const SetupPage = lazy(() => import('./pages/SetupPage/SetupPage'));
Expand All @@ -22,45 +23,47 @@ const NotFoundPage = lazy(() => import('./pages/NotFoundPage/NotFoundPage'));
export function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Auth routes (no AppShell wrapper) */}
<Route
path="setup"
element={
<Suspense fallback={<div>Loading...</div>}>
<SetupPage />
</Suspense>
}
/>
<Route
path="login"
element={
<Suspense fallback={<div>Loading...</div>}>
<LoginPage />
</Suspense>
}
/>
<ThemeProvider>
<AuthProvider>
<Routes>
{/* Auth routes (no AppShell wrapper) */}
<Route
path="setup"
element={
<Suspense fallback={<div>Loading...</div>}>
<SetupPage />
</Suspense>
}
/>
<Route
path="login"
element={
<Suspense fallback={<div>Loading...</div>}>
<LoginPage />
</Suspense>
}
/>

{/* Protected app routes (with AuthGuard and AppShell wrapper) */}
<Route element={<AuthGuard />}>
<Route element={<AppShell />}>
<Route index element={<DashboardPage />} />
<Route path="work-items" element={<WorkItemsPage />} />
<Route path="work-items/new" element={<WorkItemCreatePage />} />
<Route path="work-items/:id" element={<WorkItemDetailPage />} />
<Route path="budget" element={<BudgetPage />} />
<Route path="timeline" element={<TimelinePage />} />
<Route path="household-items" element={<HouseholdItemsPage />} />
<Route path="documents" element={<DocumentsPage />} />
<Route path="tags" element={<TagManagementPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="admin/users" element={<UserManagementPage />} />
<Route path="*" element={<NotFoundPage />} />
{/* Protected app routes (with AuthGuard and AppShell wrapper) */}
<Route element={<AuthGuard />}>
<Route element={<AppShell />}>
<Route index element={<DashboardPage />} />
<Route path="work-items" element={<WorkItemsPage />} />
<Route path="work-items/new" element={<WorkItemCreatePage />} />
<Route path="work-items/:id" element={<WorkItemDetailPage />} />
<Route path="budget" element={<BudgetPage />} />
<Route path="timeline" element={<TimelinePage />} />
<Route path="household-items" element={<HouseholdItemsPage />} />
<Route path="documents" element={<DocumentsPage />} />
<Route path="tags" element={<TagManagementPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="admin/users" element={<UserManagementPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Route>
</Route>
</Routes>
</AuthProvider>
</Routes>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
);
}
31 changes: 21 additions & 10 deletions client/src/components/AppShell/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @jest-environment jsdom
*/
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import type React from 'react';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { lazy } from 'react';
Expand Down Expand Up @@ -30,6 +31,16 @@ jest.unstable_mockModule('../../contexts/AuthContext.js', () => ({
}),
}));

// Mock ThemeContext so ThemeToggle (inside Sidebar) can call useTheme()
jest.unstable_mockModule('../../contexts/ThemeContext.js', () => ({
useTheme: () => ({
theme: 'system',
resolvedTheme: 'light',
setTheme: jest.fn(),
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
}));

describe('AppShell', () => {
let AppShellModule: typeof AppShellTypes;

Expand Down Expand Up @@ -147,7 +158,7 @@ describe('AppShell', () => {
);

// Overlay should not exist in DOM when sidebar is closed
const overlay = document.querySelector('[aria-hidden="true"]');
const overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).not.toBeInTheDocument();
});

Expand All @@ -165,7 +176,7 @@ describe('AppShell', () => {
await user.click(menuButton);

// Overlay should now exist in DOM
const overlay = document.querySelector('[aria-hidden="true"]');
const overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).toBeInTheDocument();
});

Expand All @@ -184,14 +195,14 @@ describe('AppShell', () => {
await user.click(menuButton);

// Verify overlay exists
let overlay = document.querySelector('[aria-hidden="true"]');
let overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).toBeInTheDocument();

// Click overlay to close
await user.click(overlay as HTMLElement);

// Overlay should be removed
overlay = document.querySelector('[aria-hidden="true"]');
overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).not.toBeInTheDocument();
});

Expand All @@ -210,14 +221,14 @@ describe('AppShell', () => {
await user.click(menuButton);

// Verify overlay exists
let overlay = document.querySelector('[aria-hidden="true"]');
let overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).toBeInTheDocument();

// Press Escape
await user.keyboard('{Escape}');

// Overlay should be removed
overlay = document.querySelector('[aria-hidden="true"]');
overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).not.toBeInTheDocument();
});

Expand Down Expand Up @@ -258,7 +269,7 @@ describe('AppShell', () => {

// Open
await user.click(menuButton);
let overlay = document.querySelector('[aria-hidden="true"]');
let overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).toBeInTheDocument();

// Header button should now say "Close menu"
Expand All @@ -267,15 +278,15 @@ describe('AppShell', () => {

// Close
await user.click(menuButton);
overlay = document.querySelector('[aria-hidden="true"]');
overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).not.toBeInTheDocument();

// Button should now say "Open menu" again
menuButton = screen.getByRole('button', { name: /open menu/i });

// Open again
await user.click(menuButton);
overlay = document.querySelector('[aria-hidden="true"]');
overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).toBeInTheDocument();
});

Expand Down Expand Up @@ -328,7 +339,7 @@ describe('AppShell', () => {
expect(sidebar.className).not.toMatch(/open/);

// Overlay should be removed
const overlay = document.querySelector('[aria-hidden="true"]');
const overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).not.toBeInTheDocument();
});

Expand Down
7 changes: 6 additions & 1 deletion client/src/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ export function AppShell() {
<div className={styles.appShell}>
<Sidebar isOpen={isSidebarOpen} onClose={handleCloseSidebar} />
{isSidebarOpen && (
<div className={styles.overlay} onClick={handleCloseSidebar} aria-hidden="true" />
<div
className={styles.overlay}
onClick={handleCloseSidebar}
aria-hidden="true"
data-testid="sidebar-overlay"
/>
)}
<div className={styles.mainContent}>
<Header onToggleSidebar={handleToggleSidebar} isSidebarOpen={isSidebarOpen} />
Expand Down
5 changes: 5 additions & 0 deletions client/src/components/Sidebar/Sidebar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@
outline-offset: -2px;
}

.themeSection {
/* Wrapper keeps the toggle flush with sidebar nav style */
padding: var(--spacing-1) 0;
}

.sidebarHeader {
display: none;
}
Expand Down
19 changes: 16 additions & 3 deletions client/src/components/Sidebar/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @jest-environment jsdom
*/
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import type React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithRouter } from '../../test/testUtils.js';
Expand Down Expand Up @@ -30,6 +31,18 @@ jest.unstable_mockModule('../../contexts/AuthContext.js', () => ({
}),
}));

// Mock ThemeContext so Sidebar tests don't need a ThemeProvider
const mockSetTheme = jest.fn<(theme: string) => void>();

jest.unstable_mockModule('../../contexts/ThemeContext.js', () => ({
useTheme: () => ({
theme: 'system',
resolvedTheme: 'light',
setTheme: mockSetTheme,
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
}));

describe('Sidebar', () => {
let SidebarModule: typeof SidebarTypes;
let mockOnClose: jest.MockedFunction<() => void>;
Expand Down Expand Up @@ -344,9 +357,9 @@ describe('Sidebar', () => {

// Still 9 navigation links
expect(links).toHaveLength(9);
// 2 buttons: close button + logout button
expect(buttons).toHaveLength(2);
// 3 buttons: close button + theme toggle + logout button
expect(buttons).toHaveLength(3);
expect(buttons[0]).toHaveAttribute('aria-label', 'Close menu');
expect(buttons[1]).toHaveTextContent(/logout/i);
expect(buttons[2]).toHaveTextContent(/logout/i);
});
});
5 changes: 5 additions & 0 deletions client/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NavLink } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext.js';
import { Logo } from '../Logo/Logo.js';
import { ThemeToggle } from '../ThemeToggle/ThemeToggle.js';
import styles from './Sidebar.module.css';

interface SidebarProps {
Expand Down Expand Up @@ -97,6 +98,10 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
</NavLink>
)}
<div className={styles.navSeparator} />
<div className={styles.themeSection}>
<ThemeToggle />
</div>
<div className={styles.navSeparator} />
<button
type="button"
className={styles.logoutButton}
Expand Down
43 changes: 43 additions & 0 deletions client/src/components/ThemeToggle/ThemeToggle.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
.themeToggle {
display: flex;
align-items: center;
gap: var(--spacing-2);
width: 100%;
padding: var(--spacing-3) var(--spacing-6);
background: transparent;
border: none;
color: var(--color-sidebar-text);
font: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
text-align: left;
transition: background-color var(--transition-medium);
border-radius: 0;
}

.themeToggle:hover {
background-color: var(--color-sidebar-hover);
}

.themeToggle:focus-visible {
outline: 2px solid var(--color-sidebar-focus-ring);
outline-offset: -2px;
}

.icon {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.85;
}

.label {
font-size: var(--font-size-sm);
}

/* Mobile / tablet touch target */
@media (max-width: 1024px) {
.themeToggle {
min-height: 44px;
}
}
Loading