mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
d2bda42a3b
commit
072eee6997
4 files changed
+213
-39
No files matched your search
+4
-1
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user