diff --git a/messages/de.json b/messages/de.json index f37372452..c958a3007 100644 --- a/messages/de.json +++ b/messages/de.json @@ -380,6 +380,9 @@ "themeDark": "Dunkel", "themeLight": "Hell", "collapseSidebar": "Einklappen", + "expandSidebar": "Ausklappen", + "collapseSidebarLabel": "Seitenleiste einklappen", + "expandSidebarLabel": "Seitenleiste ausklappen", "userMenu": "Nutzermenü", "accountSettings": "Kontoeinstellungen", "mood": "Stimmung", diff --git a/messages/en.json b/messages/en.json index 2a914438f..4ae37f0a7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -380,6 +380,9 @@ "themeDark": "Dark", "themeLight": "Light", "collapseSidebar": "Collapse", + "expandSidebar": "Expand", + "collapseSidebarLabel": "Collapse sidebar", + "expandSidebarLabel": "Expand sidebar", "userMenu": "User menu", "accountSettings": "Account settings", "mood": "Mood", diff --git a/messages/es.json b/messages/es.json index 8c66d2e1d..72afbd64a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -380,6 +380,9 @@ "themeDark": "Oscuro", "themeLight": "Claro", "collapseSidebar": "Contraer", + "expandSidebar": "Expandir", + "collapseSidebarLabel": "Contraer barra lateral", + "expandSidebarLabel": "Expandir barra lateral", "userMenu": "Menú de usuario", "accountSettings": "Ajustes de cuenta", "mood": "Estado de ánimo", diff --git a/messages/fr.json b/messages/fr.json index 6d333200b..e5f9712bc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -380,6 +380,9 @@ "themeDark": "Sombre", "themeLight": "Clair", "collapseSidebar": "Réduire", + "expandSidebar": "Développer", + "collapseSidebarLabel": "Réduire la barre latérale", + "expandSidebarLabel": "Développer la barre latérale", "userMenu": "Menu utilisateur", "accountSettings": "Paramètres du compte", "mood": "Humeur", diff --git a/messages/it.json b/messages/it.json index cbabb0406..3d8b61622 100644 --- a/messages/it.json +++ b/messages/it.json @@ -380,6 +380,9 @@ "themeDark": "Scuro", "themeLight": "Chiaro", "collapseSidebar": "Riduci", + "expandSidebar": "Espandi", + "collapseSidebarLabel": "Riduci barra laterale", + "expandSidebarLabel": "Espandi barra laterale", "userMenu": "Menu utente", "accountSettings": "Impostazioni account", "mood": "Umore", diff --git a/messages/ko.json b/messages/ko.json index 2f031e7b3..d64520a7a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -380,6 +380,9 @@ "themeDark": "다크", "themeLight": "라이트", "collapseSidebar": "접기", + "expandSidebar": "펼치기", + "collapseSidebarLabel": "사이드바 접기", + "expandSidebarLabel": "사이드바 펼치기", "userMenu": "사용자 메뉴", "accountSettings": "계정 설정", "mood": "기분", diff --git a/messages/pl.json b/messages/pl.json index 775c3ed9d..c0513eea3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -380,6 +380,9 @@ "themeDark": "Ciemny", "themeLight": "Jasny", "collapseSidebar": "Zwiń", + "expandSidebar": "Rozwiń", + "collapseSidebarLabel": "Zwiń pasek boczny", + "expandSidebarLabel": "Rozwiń pasek boczny", "userMenu": "Menu użytkownika", "accountSettings": "Ustawienia konta", "mood": "Nastrój", diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 7effc6cb9..a0dd34873 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; @@ -45,9 +45,23 @@ vi.mock("@/components/providers", () => ({ }), })); +// Every suite but the collapsed-rail one renders the pre-hydration paint, where +// `useMounted()` is false. The collapsed-rail suite flips this to render the +// post-mount shell, which is the only state the stored preference applies in. +const mockMountedRef = { value: false }; +vi.mock("@/hooks/use-mounted", () => ({ + useMounted: () => mockMountedRef.value, +})); + import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; -import { SidebarNav } from "../sidebar-nav"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { + SidebarCollapseToggle, + SidebarNav, + readSidebarCollapsedPref, + writeSidebarCollapsedPref, +} from "../sidebar-nav"; import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; import { visibleUtilityDestinations } from "../nav-model"; import { delegatedDomains } from "@/lib/sharing/domain-write-support"; @@ -301,7 +315,10 @@ describe(" inside somebody else's record (#939)", () => { try { return render(); } finally { + // `render()` replaces `mockUserRef.value` with a copy, so the grant + // has to come off the copy too or it leaks into later suites. delete user.accountAccess; + delete (mockUserRef.value as typeof user).accountAccess; } } @@ -337,3 +354,147 @@ describe(" inside somebody else's record (#939)", () => { expect(html).not.toContain('href="/settings/'); }); }); + +describe(" collapse control sits at the bottom, above the footer entries", () => { + /** + * The control used to be a small chevron above the first nav entry. It is + * now a full footer row heading the utility group: directly above Admin for + * an administrator, directly above Settings for everyone else. + * + * Mutation checks, run: + * - rendering `` after `renderAdminLink()` → the + * admin ordering case goes red; + * - moving it back into the `"); + const toggle = html.indexOf(TOGGLE); + const admin = html.indexOf('href="/admin"'); + const settings = html.indexOf(SETTINGS); + expect(navEnd).toBeGreaterThan(-1); + expect(toggle).toBeGreaterThan(navEnd); + expect(admin).toBeGreaterThan(toggle); + expect(settings).toBeGreaterThan(admin); + // Nothing else links in between: the only anchor opened between the + // control and the Admin href is the Admin link itself. + expect(html.slice(toggle, admin).split(" { + const html = render({ role: "USER" }); + const navEnd = html.indexOf(""); + const toggle = html.indexOf(TOGGLE); + const settings = html.indexOf(SETTINGS); + expect(toggle).toBeGreaterThan(navEnd); + expect(settings).toBeGreaterThan(toggle); + expect(html.slice(toggle, settings).split(" { + const html = render(); + const button = + /]*data-slot="sidebar-collapse-toggle"[^>]*>.*?<\/button>/.exec( + html, + )?.[0]; + expect(button).toBeDefined(); + expect(button).toContain('aria-label="Collapse sidebar"'); + expect(button).toContain('aria-expanded="true"'); + expect(button).toContain("Collapse"); + expect(button).not.toContain("sr-only"); + }); + + function renderToggle(collapsed: boolean) { + return renderToStaticMarkup( + + + {}} /> + + , + ); + } + + it("switches its labels and keeps the text for screen readers only in the rail", () => { + const html = renderToggle(true); + expect(html).toContain('aria-label="Expand sidebar"'); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('Expand'); + expect(html).toContain('type="button"'); + }); + + it("draws a different icon per state", () => { + const icon = (html: string) => /lucide-(panel-left-[a-z]+)/.exec(html)?.[1]; + expect(icon(renderToggle(false))).toBe("panel-left-close"); + expect(icon(renderToggle(true))).toBe("panel-left-open"); + }); +}); + +describe(" collapse preference persistence", () => { + const KEY = "healthlog-sidebar-collapsed"; + + function stubStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + }); + return store; + } + + afterEach(() => { + vi.unstubAllGlobals(); + mockMountedRef.value = false; + }); + + it("keeps the existing storage key, so a stored choice survives the move", () => { + const store = stubStorage(); + writeSidebarCollapsedPref(true); + expect(store.get(KEY)).toBe("true"); + expect(readSidebarCollapsedPref()).toBe(true); + writeSidebarCollapsedPref(false); + expect(store.get(KEY)).toBe("false"); + expect(readSidebarCollapsedPref()).toBe(false); + }); + + it("reads no choice as null, so the viewport default decides", () => { + stubStorage(); + expect(readSidebarCollapsedPref()).toBeNull(); + }); + + it("survives storage that throws", () => { + vi.stubGlobal("window", globalThis); + vi.stubGlobal("localStorage", { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }); + expect(readSidebarCollapsedPref()).toBeNull(); + expect(() => writeSidebarCollapsedPref(true)).not.toThrow(); + }); + + it("renders the rail with the stored collapsed choice once mounted", () => { + stubStorage({ [KEY]: "true" }); + mockMountedRef.value = true; + const html = render({ role: "ADMIN" }); + expect(html).toContain('aria-label="Expand sidebar"'); + expect(html).toContain('Expand'); + // Still at the bottom, above Admin, in the rail. + const toggle = html.indexOf('data-slot="sidebar-collapse-toggle"'); + expect(toggle).toBeGreaterThan(html.indexOf("")); + expect(html.indexOf('href="/admin"')).toBeGreaterThan(toggle); + }); +}); diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 993dda13e..b0c6baae4 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -3,12 +3,12 @@ import { useMemo, useState } from "react"; import { Bell, - ChevronsLeft, - ChevronsRight, LogOut, Monitor, Moon, MoreVertical, + PanelLeftClose, + PanelLeftOpen, Settings, Shield, Sun, @@ -52,7 +52,29 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -const STORAGE_KEY = "healthlog-sidebar-collapsed"; +const SIDEBAR_COLLAPSED_STORAGE_KEY = "healthlog-sidebar-collapsed"; + +/** + * The stored collapse choice: `true` / `false`, or `null` when this browser + * has none (or storage is unavailable), which lets the viewport default decide. + */ +export function readSidebarCollapsedPref(): boolean | null { + if (typeof window === "undefined") return null; + try { + const stored = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY); + return stored === null ? null : stored === "true"; + } catch { + return null; + } +} + +export function writeSidebarCollapsedPref(collapsed: boolean): void { + try { + localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed)); + } catch { + // Private mode or blocked storage: the choice lasts for this page only. + } +} function getInitials(name: string): string { return name @@ -280,6 +302,57 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { ); } +/** + * The sidebar's collapse / expand control, drawn as a footer nav row. + * + * It sits at the bottom of the sidebar, directly above the footer entries + * (Admin, then Settings), where the pointer already is when someone reaches + * for the account utilities. Expanded, the row shows its label beside the + * icon; collapsed, the label stays in the accessibility tree only and the + * rail tooltip names the action, like every other icon in the rail. The + * `aria-label` always names what a press does next. + */ +export function SidebarCollapseToggle({ + collapsed, + onToggle, +}: { + collapsed: boolean; + onToggle: () => void; +}) { + const { t } = useTranslations(); + const Icon = collapsed ? PanelLeftOpen : PanelLeftClose; + const actionLabel = t( + collapsed ? "nav.expandSidebarLabel" : "nav.collapseSidebarLabel", + ); + const button = ( + + ); + if (!collapsed) return button; + return ( + + {button} + + {actionLabel} + + + ); +} + export function SidebarNav() { const pathname = usePathname(); const { t } = useTranslations(); @@ -340,25 +413,15 @@ export function SidebarNav() { // mismatch, because the sidebar is only CSS-hidden below `md` and still // hydrates its DOM there. const tabletOrBelow = useIsMobile("lg"); - const [collapsedPref, setCollapsedPref] = useState(() => { - if (typeof window === "undefined") return null; - try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored === null ? null : stored === "true"; - } catch { - return null; - } - }); + const [collapsedPref, setCollapsedPref] = useState( + readSidebarCollapsedPref, + ); const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false; function toggleCollapsed() { const next = !collapsed; setCollapsedPref(next); - try { - localStorage.setItem(STORAGE_KEY, String(next)); - } catch { - // Ignore storage errors - } + writeSidebarCollapsedPref(next); } // v1.17.1 (F-1 residue) — the sidebar footer utility links derive from @@ -486,39 +549,12 @@ export function SidebarNav() { ); } - function renderCollapseToggle(className?: string) { - return ( - - - - - - {t("nav.collapseSidebar")} - - - ); - } - return (