mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
78fc22d0f8
commit
d2aba1b86b
9 files changed
+272
-64
No files matched your search
@@ -380,6 +380,9 @@
|
||||
"themeDark": "Dunkel",
|
||||
"themeLight": "Hell",
|
||||
"collapseSidebar": "Einklappen",
|
||||
"expandSidebar": "Ausklappen",
|
||||
"collapseSidebarLabel": "Seitenleiste einklappen",
|
||||
"expandSidebarLabel": "Seitenleiste ausklappen",
|
||||
"userMenu": "Nutzermenü",
|
||||
"accountSettings": "Kontoeinstellungen",
|
||||
"mood": "Stimmung",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -380,6 +380,9 @@
|
||||
"themeDark": "다크",
|
||||
"themeLight": "라이트",
|
||||
"collapseSidebar": "접기",
|
||||
"expandSidebar": "펼치기",
|
||||
"collapseSidebarLabel": "사이드바 접기",
|
||||
"expandSidebarLabel": "사이드바 펼치기",
|
||||
"userMenu": "사용자 메뉴",
|
||||
"accountSettings": "계정 설정",
|
||||
"mood": "기분",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))}
|
||||
|
||||
Reference in new issue
Block a user