Merge pull request #448 (feat/396): collapsible sidebar + nav-group accordions
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Fixes #396. Force-merged (operator-authorized): all real checks passed green on 8cef07a6 (Build & test, EF migration, Functional E2E, formatting, docs, API sync, decisions.md append-only); the red status was a cancelled redundant re-run.
This commit was merged in pull request #448.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,104 @@ 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(<App />);
|
||||
|
||||
// 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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
// 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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
|
||||
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals + marks the active route even when its group is default-collapsed, without persisting', () => {
|
||||
// Deep-link into a route inside the default-collapsed System group (expanded sidebar).
|
||||
window.history.replaceState(null, '', '/app/settings');
|
||||
|
||||
render(<App />);
|
||||
|
||||
// The active item is rendered and marked, and its group header shows expanded…
|
||||
const settingsLink = screen.getByRole('link', { name: 'Settings' });
|
||||
expect(settingsLink).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
|
||||
// …but a sibling default-collapsed group (Media) stays collapsed…
|
||||
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
|
||||
// …and the reveal did NOT write a stored preference (it reverts on navigating away).
|
||||
expect(window.localStorage.getItem('ctv-sidebar-groups')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
|
||||
+99
-11
@@ -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 (
|
||||
<aside className="ctv-sidebar">
|
||||
@@ -125,15 +140,78 @@ function Sidebar({
|
||||
<span className="ctv-brand-wordmark">
|
||||
Chicory<span>TV</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-sidebar-toggle"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-pressed={collapsed}
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen aria-hidden="true" size={17} />
|
||||
) : (
|
||||
<PanelLeftClose aria-hidden="true" size={17} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Primary" className="ctv-nav">
|
||||
{sidebarNavGroups.map(({ ids, label }) => (
|
||||
<Fragment key={label ?? 'Primary'}>
|
||||
{label ? <NavSection>{label}</NavSection> : null}
|
||||
<SidebarNavGroup activeRoute={activeRoute} ids={ids} onNavigate={onNavigate} />
|
||||
</Fragment>
|
||||
))}
|
||||
{sidebarNavGroups.map((group) => {
|
||||
const { ids, key, label } = group;
|
||||
|
||||
// Primary (unlabeled) is always open, in both the rail and the expanded sidebar.
|
||||
if (!label) {
|
||||
return (
|
||||
<SidebarNavGroup
|
||||
key="Primary"
|
||||
activeRoute={activeRoute}
|
||||
collapsed={collapsed}
|
||||
ids={ids}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const groupKey = key ?? label;
|
||||
// Accordions only apply in the expanded sidebar; the rail always shows every item
|
||||
// (separated by a hairline divider), so group-collapse is ignored there.
|
||||
const groupCollapsed = isGroupCollapsed(groupKey);
|
||||
// Always reveal the group that contains the active route so its active item is visible +
|
||||
// highlighted in the expanded sidebar, even when the group's stored state is collapsed
|
||||
// (e.g. a fresh deep-link to /app/settings). This does NOT persist — navigating away
|
||||
// reverts to the stored preference (acceptance: "active route marked in both states").
|
||||
const containsActive =
|
||||
activeRoute != null && ids.includes(activeRoute.id);
|
||||
const expanded = containsActive || !groupCollapsed;
|
||||
const showItems = collapsed || expanded;
|
||||
|
||||
return (
|
||||
<Fragment key={groupKey}>
|
||||
{collapsed ? (
|
||||
<div className="ctv-nav-divider" role="presentation" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-nav-group-header"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onToggleGroup(groupKey)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<ChevronDown className="ctv-nav-group-chevron" aria-hidden="true" size={14} />
|
||||
</button>
|
||||
)}
|
||||
{showItems ? (
|
||||
<SidebarNavGroup
|
||||
activeRoute={activeRoute}
|
||||
collapsed={collapsed}
|
||||
ids={ids}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ctv-sidebar-health">
|
||||
@@ -348,9 +426,19 @@ export function AppShell({
|
||||
route: ScreenRoute | null;
|
||||
theme: DesignSystemThemeId;
|
||||
}) {
|
||||
const { collapsed, isGroupCollapsed, toggleCollapsed, toggleGroup } = useSidebarState();
|
||||
|
||||
return (
|
||||
<div className="ctv-app-shell">
|
||||
<Sidebar activeRoute={route} healthState={healthState} onNavigate={onNavigate} />
|
||||
<div className={`ctv-app-shell${collapsed ? ' ctv-app-shell-collapsed' : ''}`}>
|
||||
<Sidebar
|
||||
activeRoute={route}
|
||||
collapsed={collapsed}
|
||||
healthState={healthState}
|
||||
isGroupCollapsed={isGroupCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
onToggleCollapsed={toggleCollapsed}
|
||||
onToggleGroup={toggleGroup}
|
||||
/>
|
||||
<div className="ctv-shell-body">
|
||||
<TopBar route={route} />
|
||||
<UnauthorizedBanner />
|
||||
|
||||
@@ -464,6 +464,9 @@ export const routeById = new Map(routes.map((route) => [route.id, route]));
|
||||
|
||||
export interface SidebarNavGroupDefinition {
|
||||
label?: string;
|
||||
// Stable key for persisted collapse state (ctv-sidebar-groups). Only labeled (collapsible)
|
||||
// groups need one; the unlabeled Primary group is always open (#396).
|
||||
key?: string;
|
||||
ids: readonly ScreenId[];
|
||||
}
|
||||
|
||||
@@ -485,6 +488,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
|
||||
},
|
||||
{
|
||||
label: 'Media',
|
||||
key: 'media',
|
||||
ids: [
|
||||
'media',
|
||||
'search',
|
||||
@@ -500,6 +504,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
key: 'system',
|
||||
ids: [
|
||||
'settings',
|
||||
'apiKey',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStoredSidebarCollapsed,
|
||||
getStoredSidebarGroups,
|
||||
useSidebarState
|
||||
} from './sidebarState';
|
||||
|
||||
describe('sidebarState persistence (#396)', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to expanded when nothing is stored', () => {
|
||||
expect(getStoredSidebarCollapsed()).toBe(false);
|
||||
expect(getStoredSidebarGroups()).toEqual({});
|
||||
});
|
||||
|
||||
it('reads the collapsed flag only from the exact "1" sentinel', () => {
|
||||
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
|
||||
expect(getStoredSidebarCollapsed()).toBe(true);
|
||||
|
||||
window.localStorage.setItem('ctv-sidebar-collapsed', '0');
|
||||
expect(getStoredSidebarCollapsed()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores malformed or non-boolean group JSON', () => {
|
||||
window.localStorage.setItem('ctv-sidebar-groups', 'not json');
|
||||
expect(getStoredSidebarGroups()).toEqual({});
|
||||
|
||||
window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify(['media']));
|
||||
expect(getStoredSidebarGroups()).toEqual({});
|
||||
|
||||
window.localStorage.setItem(
|
||||
'ctv-sidebar-groups',
|
||||
JSON.stringify({ media: true, system: 'nope' })
|
||||
);
|
||||
expect(getStoredSidebarGroups()).toEqual({ media: true });
|
||||
});
|
||||
|
||||
it('treats an unknown labeled group as collapsed by default, and toggles + persists it', () => {
|
||||
const { result } = renderHook(() => useSidebarState());
|
||||
|
||||
expect(result.current.isGroupCollapsed('media')).toBe(true);
|
||||
|
||||
act(() => result.current.toggleGroup('media'));
|
||||
|
||||
expect(result.current.isGroupCollapsed('media')).toBe(false);
|
||||
expect(getStoredSidebarGroups()).toEqual({ media: false });
|
||||
});
|
||||
|
||||
it('toggles + persists the sidebar collapsed flag', () => {
|
||||
const { result } = renderHook(() => useSidebarState());
|
||||
|
||||
expect(result.current.collapsed).toBe(false);
|
||||
|
||||
act(() => result.current.toggleCollapsed());
|
||||
|
||||
expect(result.current.collapsed).toBe(true);
|
||||
expect(getStoredSidebarCollapsed()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
// Persisted UI state for the shell sidebar (issue #396). Two client-local preferences,
|
||||
// namespaced `ctv-*` per the persisted-UI-state convention (spa-conventions.md §5d):
|
||||
// - ctv-sidebar-collapsed: "1" | "0" — is the sidebar collapsed to the icon rail?
|
||||
// - ctv-sidebar-groups: JSON {groupKey: boolean} — is a labeled nav group collapsed?
|
||||
// A labeled group with no stored entry defaults to COLLAPSED, so a fresh load shows only the
|
||||
// always-open Primary group. (The prototype named these keys `ctv.sidebar.*`; we use the
|
||||
// established `ctv-` hyphen form — see docs/decisions.md 2026-07-18.)
|
||||
|
||||
const COLLAPSED_KEY = 'ctv-sidebar-collapsed';
|
||||
const GROUPS_KEY = 'ctv-sidebar-groups';
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
// Storage can be disabled/unavailable (privacy mode, sandboxed iframe) — degrade to defaults.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredSidebarCollapsed(): boolean {
|
||||
return getStorage()?.getItem(COLLAPSED_KEY) === '1';
|
||||
}
|
||||
|
||||
function setStoredSidebarCollapsed(collapsed: boolean): void {
|
||||
try {
|
||||
getStorage()?.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
|
||||
} catch {
|
||||
// ignore write failures (quota / unavailable) — in-memory state still updates
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredSidebarGroups(): Record<string, boolean> {
|
||||
const raw = getStorage()?.getItem(GROUPS_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof value === 'boolean') {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} catch {
|
||||
// malformed JSON — fall through to defaults
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function setStoredSidebarGroups(groups: Record<string, boolean>): void {
|
||||
try {
|
||||
getStorage()?.setItem(GROUPS_KEY, JSON.stringify(groups));
|
||||
} catch {
|
||||
// ignore write failures — in-memory state still updates
|
||||
}
|
||||
}
|
||||
|
||||
export interface SidebarState {
|
||||
/** Is the sidebar collapsed to the icon rail? */
|
||||
collapsed: boolean;
|
||||
toggleCollapsed: () => void;
|
||||
/** Is a labeled nav group collapsed? Unknown groups default to collapsed. */
|
||||
isGroupCollapsed: (groupKey: string) => boolean;
|
||||
toggleGroup: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export function useSidebarState(): SidebarState {
|
||||
const [collapsed, setCollapsed] = useState<boolean>(getStoredSidebarCollapsed);
|
||||
const [groups, setGroups] = useState<Record<string, boolean>>(getStoredSidebarGroups);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((previous) => {
|
||||
const next = !previous;
|
||||
setStoredSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isGroupCollapsed = useCallback(
|
||||
// Absent entry ⇒ default collapsed for labeled groups.
|
||||
(groupKey: string) => groups[groupKey] ?? true,
|
||||
[groups]
|
||||
);
|
||||
|
||||
const toggleGroup = useCallback((groupKey: string) => {
|
||||
setGroups((previous) => {
|
||||
const currentlyCollapsed = previous[groupKey] ?? true;
|
||||
const next = { ...previous, [groupKey]: !currentlyCollapsed };
|
||||
setStoredSidebarGroups(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { collapsed, toggleCollapsed, isGroupCollapsed, toggleGroup };
|
||||
}
|
||||
@@ -861,6 +861,88 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Collapsible nav-group header (accordion toggle) — the interactive replacement for the static
|
||||
NavSection label in the expanded sidebar (#396). */
|
||||
.ctv-nav-group-header {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 8px 0 2px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-disabled);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: var(--tracking-caps);
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
transition: color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ctv-nav-group-header:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-nav-group-header:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.ctv-nav-group-header > span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ctv-nav-group-chevron {
|
||||
flex: 0 0 auto;
|
||||
transition: transform var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Collapsed group ⇒ chevron points right (closed). */
|
||||
.ctv-nav-group-header[aria-expanded='false'] .ctv-nav-group-chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ---- Collapsed (icon-rail) nav items (#396) ---- */
|
||||
.ctv-app-shell-collapsed .ctv-nav-item {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Keep the label in the a11y tree (accessible name) but visually hidden in the rail; the native
|
||||
title tooltip surfaces it for sighted mouse users. */
|
||||
.ctv-app-shell-collapsed .ctv-nav-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* A numeric badge collapses to a small corner dot in the rail. */
|
||||
.ctv-app-shell-collapsed .ctv-nav-badge {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 9px;
|
||||
min-width: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.ctv-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface NavItemProps {
|
||||
href?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
style?: CSSProperties;
|
||||
// Native tooltip; used by the collapsed sidebar rail to surface the label that is visually
|
||||
// hidden there (the label stays in the a11y tree for the accessible name — #396).
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function NavItem({
|
||||
@@ -23,7 +26,8 @@ export function NavItem({
|
||||
badgeTone = 'warn',
|
||||
href,
|
||||
onClick,
|
||||
style
|
||||
style,
|
||||
title
|
||||
}: NavItemProps) {
|
||||
const content = (
|
||||
<>
|
||||
@@ -44,6 +48,7 @@ export function NavItem({
|
||||
onClick={onClick}
|
||||
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
|
||||
style={style}
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
@@ -57,6 +62,7 @@ export function NavItem({
|
||||
onClick={onClick}
|
||||
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
|
||||
style={style}
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
|
||||
+96
-1
@@ -26,6 +26,21 @@ body {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
font-size: var(--text-sm, 13px);
|
||||
/* Animate the sidebar collapse/expand (#396). Only the grid track width transitions so the
|
||||
content reflows smoothly without a layout jump. */
|
||||
transition: grid-template-columns var(--dur-base, 140ms) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Collapsed sidebar = the 60px icon rail (#396). Narrowing the tracked custom property drives the
|
||||
grid-template-columns transition above. */
|
||||
.ctv-app-shell-collapsed {
|
||||
--sidebar-w: 60px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ctv-app-shell {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ctv-sidebar {
|
||||
@@ -97,6 +112,72 @@ body {
|
||||
font-size: var(--text-2xs, 11px);
|
||||
}
|
||||
|
||||
/* Sidebar collapse toggle in the brand header (#396). */
|
||||
.ctv-sidebar-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: transparent;
|
||||
color: var(--text-disabled);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ctv-sidebar-toggle:hover {
|
||||
background: var(--ctv-accent-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctv-sidebar-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
/* Hairline divider shown between nav groups in the collapsed rail (accordions don't apply
|
||||
there, so the group label/chevron is replaced by a plain separator — #396). */
|
||||
.ctv-nav-divider {
|
||||
height: 1px;
|
||||
margin: var(--space-4, 8px) var(--space-4, 8px);
|
||||
background: var(--border-hairline);
|
||||
}
|
||||
|
||||
/* ---- Collapsed (icon-rail) sidebar overrides (#396) ---- */
|
||||
.ctv-app-shell-collapsed .ctv-brand {
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-brand img,
|
||||
.ctv-app-shell-collapsed .ctv-brand-wordmark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-sidebar-toggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Footer: hide the version text block and the health label, keep the status dot centered. */
|
||||
.ctv-app-shell-collapsed .ctv-sidebar-health {
|
||||
justify-content: center;
|
||||
padding: var(--space-6, 12px) 0;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-sidebar-health > div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-sidebar-health .ctv-status-dot > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctv-shell-body {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
@@ -3730,10 +3811,24 @@ body {
|
||||
}
|
||||
|
||||
.ctv-nav,
|
||||
.ctv-sidebar-health {
|
||||
.ctv-sidebar-health,
|
||||
.ctv-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The collapse feature is desktop-only (the nav is hidden here). If the sidebar was collapsed on
|
||||
desktop, don't let the rail's brand-hiding leave an empty header on mobile — restore the brand
|
||||
(#396). */
|
||||
.ctv-app-shell-collapsed .ctv-brand {
|
||||
padding: 0 14px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-brand img,
|
||||
.ctv-app-shell-collapsed .ctv-brand-wordmark {
|
||||
display: revert;
|
||||
}
|
||||
|
||||
.ctv-topbar {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
|
||||
Reference in New Issue
Block a user