fix(nav): bottom-nav 5+More overflow protects 44px tap targets

The 7-item bottom nav collapsed each touch target to ~50 px wide on
a 375 px viewport — only just-passing WCAG 2.5.5's 44 px floor and
visibly cramped on iPhone-13-mini-class screens. The mobile audit
(`.planning/phase-A5-mobile-findings.md`) recommended 5+More.

Primary strip keeps the four most-used surfaces plus Insights:
Home, Measurements, Mood, Medications, Insights. Targets and
Achievements move into a Radix `<Sheet>` triggered by a sixth
"More" entry. Order matches the v1.4.15 layout for the four core
items so muscle memory holds.

The More button mirrors the same `min-h-11 min-w-11` hit area;
`aria-haspopup="dialog"` + `aria-expanded` keeps screen-reader
behaviour tight; the sheet renders bottom-anchored with safe-area
padding so the inner links stay reachable above the iPhone home
indicator. Active styling lights the More entry whenever any of
its overflow children is the current route, so the user always
knows which strip-level link "owns" their position.
This commit is contained in:
Marc-André Bombeck committed 2026-05-10 00:39:59 +02:00
1 parent d2bda42a3b
commit 072eee6997
4 files changed
+213 -39

No files matched your search

+4 -1
View File
@@ -169,7 +169,10 @@
"sidebar": "Sidebar",
"mainNavigation": "Hauptnavigation",
"mobileNavigation": "Mobile Navigation",
"loadingScreen": "Laden…"
"loadingScreen": "Laden…",
"more": "Mehr",
"moreSheetTitle": "Mehr",
"moreSheetDescription": "Seiten, die nicht in die untere Leiste passen."
},
"auth": {
"login": "Anmelden",
+4 -1
View File
@@ -169,7 +169,10 @@
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
"mobileNavigation": "Mobile navigation",
"loadingScreen": "Loading…"
"loadingScreen": "Loading…",
"more": "More",
"moreSheetTitle": "More",
"moreSheetDescription": "Pages that don't fit the bottom bar."
},
"auth": {
"login": "Sign in",
@@ -0,0 +1,75 @@
/**
* v1.4.16 Wave-C MED — bottom-nav 5+More layout.
*
* SSR contract for `<BottomNav>`:
* - exactly five primary links + one "More" button render in the strip
* - the More button is keyboard-reachable as a `<button>` with
* `aria-haspopup="dialog"`
* - Targets + Achievements live in the More sheet (closed by default
* under SSR), not in the always-visible strip
* - WCAG 2.5.5 floor: every entry is `min-h-11 min-w-11`
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// `<BottomNav>` reads the active route from `usePathname()`. Stub
// next/navigation so the SSR render works without an App-Router runtime.
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { BottomNav } from "../bottom-nav";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<BottomNav />
</I18nProvider>,
);
}
describe("<BottomNav> 5+More layout", () => {
it("renders the five primary anchors", () => {
const html = render();
for (const href of [
"/",
"/measurements",
"/mood",
"/medications",
"/insights",
]) {
expect(html).toContain(`href="${href}"`);
}
});
it("renders an overflow More button via aria-haspopup", () => {
const html = render();
expect(html).toContain('data-testid="bottom-nav-more"');
expect(html).toMatch(/aria-haspopup="dialog"/);
expect(html).toMatch(/aria-expanded="false"/);
});
it("each primary entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => {
const html = render();
// Count `min-h-11 min-w-11` occurrences. Six is the expected floor
// (five anchors + the More button); we don't hard-pin because a
// future entry would also satisfy the contract.
const matches = html.match(/min-h-11 min-w-11/g) ?? [];
expect(matches.length).toBeGreaterThanOrEqual(6);
});
it("More button carries an accessible label", () => {
const html = render();
// Attribute order isn't guaranteed by React's renderer, so match
// both possible directions instead of pinning the order.
const hasLabelFirst = /aria-label="More"[^>]*data-testid="bottom-nav-more"/.test(
html,
);
const hasTestidFirst = /data-testid="bottom-nav-more"[^>]*aria-label="More"/.test(
html,
);
expect(hasLabelFirst || hasTestidFirst).toBe(true);
});
});
+130 -37
View File
@@ -4,6 +4,7 @@ import {
Activity,
Home,
Lightbulb,
MoreHorizontal,
Pill,
Target,
Trophy,
@@ -11,15 +12,42 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
const navItems = [
interface NavLink {
href: string;
tKey: string;
icon: typeof Home;
}
// v1.4.16 Wave-C MED — bottom-nav was rendering all 7 destinations
// edge-to-edge on a 375 px viewport, which collapsed each touch
// target to ~50 px wide and made the WCAG 2.5.5 44 px floor only
// just-pass on iPhone-13-mini-class screens. The mobile audit
// recommended 5+More — so the four most-used surfaces stay in the
// strip (Home, Measurements, Mood, Medications) plus Insights as
// the fifth, and Targets + Achievements move into a "More" sheet.
//
// Order is preserved relative to the v1.4.15 bottom-nav so muscle
// memory keeps working for the four core actions.
const PRIMARY: ReadonlyArray<NavLink> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
];
const OVERFLOW: ReadonlyArray<NavLink> = [
{ href: "/targets", tKey: "nav.targets", icon: Target },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
];
@@ -27,44 +55,109 @@ const navItems = [
export function BottomNav() {
const pathname = usePathname();
const { t } = useTranslations();
const [moreOpen, setMoreOpen] = useState(false);
const items = navItems;
function isActiveLink(href: string) {
return href === "/" ? pathname === "/" : pathname.startsWith(href);
}
// The "More" entry is treated as active when any of its children
// is the current page — gives the user a clue that they've drilled
// into a sub-route via overflow.
const overflowActive = OVERFLOW.some((item) => isActiveLink(item.href));
return (
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{items.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={t(item.tKey)}
aria-current={isActive ? "page" : undefined}
// Touch target sized to WCAG 2.5.5 minimum (44×44 CSS px). The
// outer min-h-11 min-w-11 is the actual hit area; the icon
// stays visually centered at 20px so the design doesn't shift.
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
<item.icon className="h-5 w-5" />
{isActive && (
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</Link>
);
})}
</div>
</nav>
<>
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{PRIMARY.map((item) => {
const isActive = isActiveLink(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={t(item.tKey)}
aria-current={isActive ? "page" : undefined}
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
// outer min-h-11 min-w-11 is the actual hit area; the icon
// stays visually centered at 20px so the design doesn't shift.
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
<item.icon className="h-5 w-5" />
{isActive && (
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</Link>
);
})}
<button
type="button"
aria-label={t("nav.more")}
aria-haspopup="dialog"
aria-expanded={moreOpen}
aria-current={overflowActive ? "page" : undefined}
onClick={() => setMoreOpen(true)}
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
overflowActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
data-testid="bottom-nav-more"
>
<MoreHorizontal className="h-5 w-5" />
{overflowActive && (
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</button>
</div>
</nav>
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
<SheetContent
side="bottom"
// The sheet auto-sizes to its content; padding-bottom respects
// the iOS safe area so the inner buttons stay reachable above
// the home-indicator on iPhone-X-class devices.
className="rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)] md:hidden"
data-testid="bottom-nav-more-sheet"
>
<SheetHeader>
<SheetTitle>{t("nav.moreSheetTitle")}</SheetTitle>
<SheetDescription>{t("nav.moreSheetDescription")}</SheetDescription>
</SheetHeader>
<div className="grid grid-cols-1 gap-2 px-4 pb-4">
{OVERFLOW.map((item) => {
const isActive = isActiveLink(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMoreOpen(false)}
aria-current={isActive ? "page" : undefined}
className={cn(
"border-border flex min-h-11 items-center gap-3 rounded-lg border px-4 py-3 transition-colors",
isActive
? "text-primary bg-primary/5"
: "text-foreground hover:bg-accent/40",
)}
>
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="text-sm font-medium">{t(item.tKey)}</span>
</Link>
);
})}
</div>
</SheetContent>
</Sheet>
</>
);
}