feat(nav): move the sidebar collapse control to the bottom of the sidebar

The small chevron above the first nav entry becomes a full footer row that
heads the utility group: directly above Admin for an administrator, directly
above Settings for everyone else. PanelLeftClose / PanelLeftOpen mark the two
states; the label reads Collapse while expanded and stays for screen readers
only in the icon rail, where the tooltip names the action. aria-label switches
between collapse and expand; the stored choice keeps its storage key.

The width transition now honours reduced motion. Adds the expand and
sidebar-action labels in all seven locales.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:47:48 +02:00
1 parent 78fc22d0f8
commit d2aba1b86b
9 files changed
+272 -64

No files matched your search

+3
View File
@@ -380,6 +380,9 @@
"themeDark": "Dunkel",
"themeLight": "Hell",
"collapseSidebar": "Einklappen",
"expandSidebar": "Ausklappen",
"collapseSidebarLabel": "Seitenleiste einklappen",
"expandSidebarLabel": "Seitenleiste ausklappen",
"userMenu": "Nutzermenü",
"accountSettings": "Kontoeinstellungen",
"mood": "Stimmung",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -380,6 +380,9 @@
"themeDark": "다크",
"themeLight": "라이트",
"collapseSidebar": "접기",
"expandSidebar": "펼치기",
"collapseSidebarLabel": "사이드바 접기",
"expandSidebarLabel": "사이드바 펼치기",
"userMenu": "사용자 메뉴",
"accountSettings": "계정 설정",
"mood": "기분",
+3
View File
@@ -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",
@@ -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("<SidebarNav> 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("<SidebarNav> inside somebody else's record (#939)", () => {
expect(html).not.toContain('href="/settings/');
});
});
describe("<SidebarNav> 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 `<SidebarCollapseToggle>` after `renderAdminLink()` → the
* admin ordering case goes red;
* - moving it back into the `<nav>` above the first entry → both ordering
* cases go red on the main-nav bound;
* - dropping `sr-only` from the collapsed label → the rail case goes red.
*/
const TOGGLE = 'data-slot="sidebar-collapse-toggle"';
const SETTINGS = 'data-slot="nav-settings-link"';
it("renders exactly one collapse control", () => {
const html = render();
expect(html.split(TOGGLE)).toHaveLength(2);
});
it("sits after the main navigation and directly above Admin, then Settings, for an administrator", () => {
const html = render({ role: "ADMIN" });
const navEnd = html.indexOf("</nav>");
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("<a ")).toHaveLength(2);
});
it("sits directly above Settings when there is no Admin entry", () => {
const html = render({ role: "USER" });
const navEnd = html.indexOf("</nav>");
const toggle = html.indexOf(TOGGLE);
const settings = html.indexOf(SETTINGS);
expect(toggle).toBeGreaterThan(navEnd);
expect(settings).toBeGreaterThan(toggle);
expect(html.slice(toggle, settings).split("<a ")).toHaveLength(2);
});
it("shows a visible Collapse label with the sidebar expanded", () => {
const html = render();
const button =
/<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("<span>Collapse</span>");
expect(button).not.toContain("sr-only");
});
function renderToggle(collapsed: boolean) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TooltipProvider>
<SidebarCollapseToggle collapsed={collapsed} onToggle={() => {}} />
</TooltipProvider>
</I18nProvider>,
);
}
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('<span class="sr-only">Expand</span>');
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("<SidebarNav> collapse preference persistence", () => {
const KEY = "healthlog-sidebar-collapsed";
function stubStorage(initial: Record<string, string> = {}) {
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('<span class="sr-only">Expand</span>');
// Still at the bottom, above Admin, in the rail.
const toggle = html.indexOf('data-slot="sidebar-collapse-toggle"');
expect(toggle).toBeGreaterThan(html.indexOf("</nav>"));
expect(html.indexOf('href="/admin"')).toBeGreaterThan(toggle);
});
});
+88 -62
View File
@@ -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 = (
<button
type="button"
onClick={onToggle}
aria-label={actionLabel}
aria-expanded={!collapsed}
data-slot="sidebar-collapse-toggle"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 flex w-full items-center rounded-lg text-sm font-medium transition-colors outline-none focus-visible:ring-[3px]",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
)}
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
<span className={collapsed ? "sr-only" : undefined}>
{t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")}
</span>
</button>
);
if (!collapsed) return button;
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{actionLabel}
</TooltipContent>
</Tooltip>
);
}
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<boolean | null>(() => {
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<boolean | null>(
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 (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleCollapsed}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent z-20 rounded-md p-1 transition-colors",
className,
)}
aria-label={t("nav.collapseSidebar")}
aria-expanded={!collapsed}
>
{collapsed ? (
<ChevronsRight className="h-3.5 w-3.5" />
) : (
<ChevronsLeft className="h-3.5 w-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{t("nav.collapseSidebar")}
</TooltipContent>
</Tooltip>
);
}
return (
<TooltipProvider delayDuration={0}>
<aside
aria-label={t("nav.sidebar")}
className={cn(
"bg-sidebar border-sidebar-border relative hidden h-full flex-shrink-0 border-r transition-[width] duration-200 md:flex md:flex-col",
"bg-sidebar border-sidebar-border relative hidden h-full flex-shrink-0 border-r transition-[width] duration-200 motion-reduce:transition-none md:flex md:flex-col",
collapsed ? "w-16" : "w-64",
)}
>
@@ -554,22 +590,6 @@ export function SidebarNav() {
aria-label={t("nav.mainNavigation")}
className={cn("flex-1 overflow-y-auto", collapsed ? "p-1.5" : "p-3")}
>
{collapsed ? (
<div className="mb-1 flex justify-center">
{renderCollapseToggle()}
</div>
) : (
// v1.4.33 IW7 — drop the "Home" group label. The sidebar
// has exactly one nav group, and the first entry is
// already "Dashboard" pointing to `/`, so the previous
// "HOME / Dashboard" pairing read as if there were two
// separate destinations for the homepage. The collapse
// toggle stays anchored to the top-right of the strip so
// the visual rhythm of the sidebar header is unchanged.
<div className="relative mb-1 flex h-5 items-center justify-end">
{renderCollapseToggle()}
</div>
)}
<div className="space-y-1">
{visibleNavItems.map((item) => {
const isActive = isNavDestinationActive(
@@ -629,8 +649,14 @@ export function SidebarNav() {
{/* Bottom utility links — the shared utility tail (minus
Notifications, which lives in the avatar menu) with the
role-gated Admin entry inserted before Settings. */}
role-gated Admin entry inserted before Settings. The collapse
control heads the group, so it sits directly above Admin for an
administrator and directly above Settings for everyone else. */}
<div className={cn("space-y-1 pb-1", collapsed ? "px-1.5" : "px-3")}>
<SidebarCollapseToggle
collapsed={collapsed}
onToggle={toggleCollapsed}
/>
{footerUtilityItems
.filter((item) => !isSettingsUtilityDestination(item))
.map((item) => renderUtilityLink(item))}