diff --git a/docs/decisions.md b/docs/decisions.md index 962dbbd9f..559a70a85 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1807,3 +1807,33 @@ page to completeness** — rather than option (b) (a generous cap + truncation s instead of the single-shot fetch; the #221 stale-query guard and the single add POST are unchanged. - **Out of scope (unchanged):** the add POST itself still accepts the full merged id set in one request body — bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET. + +## 2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396) + +The shell sidebar (`web/src/app/AppShell.tsx`) gained (a) a header toggle that collapses it to a 60px +icon rail and (b) collapsible accordions per **labeled** nav group (Media, System); the unlabeled +**Primary** group is always open. Mirrors the Claude Design prototype's updated `Sidebar`. + +- **State lives in a small hook, not App.** `web/src/app/sidebarState.ts` `useSidebarState()` owns both + pieces of state + persistence; `AppShell` consumes it (nothing else needs it) and stamps + `ctv-app-shell-collapsed` on the shell root so the collapse is CSS-driven from one class. +- **Persistence keys use the established `ctv-` hyphen convention, NOT the prototype's dotted names.** + The issue quoted `ctv.sidebar.collapsed` / `ctv.sidebar.groups`, but every existing client-local pref + is hyphenated (`ctv-theme`, `ctv-logs-page-size` — spa-conventions §5d), so we use + **`ctv-sidebar-collapsed`** (`"1"`/`"0"`) and **`ctv-sidebar-groups`** (JSON `{groupKey: boolean}`, + boolean = *collapsed*). Deliberate deviation from the issue's literal key text in favour of the repo + convention the issue itself points to; helpers validate/parse defensively (bad JSON / non-boolean + values → default). +- **Labeled groups default to COLLAPSED** (absent `ctv-sidebar-groups` entry ⇒ collapsed), so a fresh + load shows only Primary — matching the prototype ("default-collapsed, leaving only Primary visible"). + A behavior change for existing users; `App.test.tsx`'s shell/nav suite seeds the two groups open + because it clicks Media/System nav links directly (the accordion behavior is covered in its own + describe). +- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored — every + item renders as an icon (label kept in the a11y tree via an sr-only span so the accessible name/tests + survive; surfaced as a native `title` tooltip), groups separated by a hairline divider, numeric + badges shown as a corner dot. The active-route indicator (left rail bar + active background) works in + both states. +- **Group keys are explicit + stable** (`SidebarNavGroupDefinition.key`: `'media'`, `'system'`) rather + than derived from the label, so renaming a label doesn't silently orphan persisted state. +- No route/screen was added or redirected (shell-chrome only), so no `blazor-route-parity.md` change. diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 14fce2cb7..9414579b1 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -459,3 +459,34 @@ integration. This module is intentionally reusable beyond SmartCollections — ChannelBuilder and Auto-Tune's inline query editing (#69) are candidate future consumers, tracked as separate follow-up issues rather than wired in #176. + +## 13. Collapsible sidebar + nav-group accordions (#396) + +The shell sidebar (`web/src/app/AppShell.tsx`) supports two independent, persisted collapse states. +Both are shell chrome — no screen participates. + +- **State + persistence** live in `web/src/app/sidebarState.ts` (`useSidebarState()`), consumed by + `AppShell` alone. It follows the §5d client-local-prefs pattern (a try/catch `getStorage()`, a + validating getter, a write-through setter) over two namespaced keys: + - `ctv-sidebar-collapsed` — `"1"`/`"0"`; is the sidebar collapsed to the 60px **icon rail**? + - `ctv-sidebar-groups` — JSON `{groupKey: boolean}` where the boolean is **collapsed**. A labeled + group with **no stored entry defaults to collapsed**, so a fresh load shows only the always-open + **Primary** group. (Keys are the `ctv-` hyphen form, not the prototype's `ctv.sidebar.*` — see + `decisions.md` 2026-07-18.) +- **`AppShell` stamps `ctv-app-shell-collapsed` on the shell root** when collapsed; the rail look is + entirely CSS-driven from that one class (`shell.css` narrows the tracked `--sidebar-w` to 60px and + transitions `grid-template-columns`; `@media (prefers-reduced-motion: reduce)` drops the transition). +- **Nav is inventory-driven** from `sidebarNavGroups` (`app/routes.tsx`). Only **labeled** groups are + collapsible; each has an explicit stable `key` (`'media'`, `'system'`) used for the persisted map — + don't derive the key from the label (a rename would orphan persisted state). The unlabeled Primary + group is always rendered. +- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored: every + item renders (icon-only), groups separated by a `.ctv-nav-divider`. The nav item's **label stays in + the a11y tree** (visually hidden via CSS, not `display:none`) so the accessible name — and every + `getByRole('link', { name })` test — still resolves; the label is also passed as the native `title` + tooltip (`NavItem` gained a `title` prop). Numeric badges collapse to a corner dot. The active-route + indicator works in both states. +- **Testing note**: `App.test.tsx`'s shell/nav suite clicks Media/System nav links directly, so its + `beforeEach` **seeds both groups open** (`ctv-sidebar-groups`); the default-collapsed / accordion / + rail behavior is covered in its own `describe('collapsible sidebar (#396)')`, and the persistence + helpers have a colocated `sidebarState.test.ts`. diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 9bea879ea..ef94623c4 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -16,6 +16,13 @@ describe('ChicoryTV SPA scaffold', () => { beforeEach(() => { window.localStorage.clear(); + // #396: the sidebar's Media/System nav groups now default to COLLAPSED. The shell/nav tests + // below click nav links inside those groups directly, so keep them expanded here; the + // collapse/accordion behavior itself is covered in its own describe block at the end. + window.localStorage.setItem( + 'ctv-sidebar-groups', + JSON.stringify({ media: false, system: false }) + ); document.documentElement.removeAttribute('data-theme'); window.history.replaceState(null, '', '/app'); vi.restoreAllMocks(); @@ -632,6 +639,88 @@ describe('ChicoryTV SPA scaffold', () => { expect(window.location.pathname).toBe('/app/settings/scanner'); expect(window.fetch).toHaveBeenCalledWith('/api/v1/settings/scanner', expect.any(Object)); }); + + // #396 — collapsible sidebar + collapsible nav groups. These tests want the real defaults + // (Media/System groups collapsed), so undo the outer beforeEach's "keep groups open" seed. + describe('collapsible sidebar (#396)', () => { + beforeEach(() => { + window.localStorage.removeItem('ctv-sidebar-groups'); + }); + + it('collapses Media/System nav groups by default, leaving Primary visible', () => { + render(); + + // Primary group items always render. + expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Channels' })).toBeInTheDocument(); + + // Labeled groups start collapsed → their items are not rendered, but the accordion headers are. + expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Media', expanded: false })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'System', expanded: false })).toBeInTheDocument(); + }); + + it('expands a nav group when its header is clicked and persists the open state', () => { + render(); + + const systemHeader = screen.getByRole('button', { name: 'System', expanded: false }); + fireEvent.click(systemHeader); + + expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument(); + + const stored = JSON.parse(window.localStorage.getItem('ctv-sidebar-groups') ?? '{}'); + expect(stored.system).toBe(false); + }); + + it('restores expanded groups from localStorage on load', () => { + window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify({ media: false })); + + render(); + + // Media restored open; System still default-collapsed. + expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument(); + }); + + it('collapses the sidebar to the icon rail and persists it', () => { + const { container } = render(); + + const shell = container.querySelector('.ctv-app-shell'); + expect(shell).not.toHaveClass('ctv-app-shell-collapsed'); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })); + + expect(shell).toHaveClass('ctv-app-shell-collapsed'); + expect(window.localStorage.getItem('ctv-sidebar-collapsed')).toBe('1'); + // The toggle now offers to expand again. + expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument(); + }); + + it('shows every group item in the rail regardless of accordion state (labels kept for a11y)', () => { + // Sidebar collapsed on load; groups at their default (collapsed) accordion state. + window.localStorage.setItem('ctv-sidebar-collapsed', '1'); + + const { container } = render(); + + expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed'); + // Accordions do not apply in the rail: Media/System items are all present (label stays in the + // a11y tree so the link keeps its accessible name), and no accordion header is rendered. + expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'System' })).not.toBeInTheDocument(); + }); + + it('restores the collapsed sidebar from localStorage on load', () => { + window.localStorage.setItem('ctv-sidebar-collapsed', '1'); + + const { container } = render(); + + expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed'); + expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument(); + }); + }); }); function jsonResponse(body: unknown, status = 200): Response { diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index 5663c6c21..64097cf8b 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -17,6 +17,8 @@ import { ClipboardCopy, Info, ListVideo, + PanelLeftClose, + PanelLeftOpen, Plus, Search } from 'lucide-react'; @@ -29,8 +31,7 @@ import { import { Button, IconButton, - NavItem, - NavSection + NavItem } from '../components'; import { designSystemThemes, @@ -38,6 +39,7 @@ import { } from '../designSystem'; import { usePrimaryActionHandler } from '../primaryAction'; import { navigateToPath } from '../routing'; +import { useSidebarState } from './sidebarState'; import { DashboardHealthSummary } from '../screens/DashboardScreen'; import { UnauthorizedBanner } from '../UnauthorizedBanner'; import { UserMenu } from '../UserMenu'; @@ -53,10 +55,12 @@ type NavigateHandler = (route: ScreenRoute, event: MouseEvent) => void; function SidebarNavGroup({ activeRoute, + collapsed, ids, onNavigate }: { activeRoute: ScreenRoute | null; + collapsed: boolean; ids: readonly ScreenId[]; onNavigate: NavigateHandler; }) { @@ -70,6 +74,9 @@ function SidebarNavGroup({ key={route.id} icon={route.icon} label={route.label} + // In the collapsed rail, the label is visually hidden (kept for a11y) so surface it + // as a native tooltip; expanded shows the label inline so a tooltip would be redundant. + title={collapsed ? route.label : undefined} active={route.id === activeRoute?.id} badge={route.badge} badgeTone="warn" @@ -111,12 +118,20 @@ function ThemeSwitcher({ function Sidebar({ activeRoute, + collapsed, healthState, - onNavigate + isGroupCollapsed, + onNavigate, + onToggleCollapsed, + onToggleGroup }: { activeRoute: ScreenRoute | null; + collapsed: boolean; healthState: DashboardHealthQueryState; + isGroupCollapsed: (groupKey: string) => boolean; onNavigate: NavigateHandler; + onToggleCollapsed: () => void; + onToggleGroup: (groupKey: string) => void; }) { return (