Files
HealthLog/src/components/layout/bottom-nav.tsx
T
Marc-André Bombeck 1768b1d971 ux: i18n leaks, focus rings, tap-target floor, admin chip-scroll, empty-state CTA vocabulary
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.

i18n leaks (3 primitives):
  - Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
    so a German user heard 'Close, button' on every modal. Both lift
    `useTranslations()` and read `common.close`.
  - ChartSkeleton's sr-only loading announcement was English-only.
    The component is already a client island; reads `charts.loadingLabel`
    from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
  - Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
    was hardcoded. Routes through `t("nav.breadcrumb")` with a new
    key in each locale.

Focus rings (mobile + tablet nav):
  - Bottom-nav primary links + the More overflow trigger relied on a
    `text-muted-foreground` → `text-primary` colour transition only;
    keyboard / switch-control users saw no focus indicator at all.
    Added `focus-visible:ring-ring/50 focus-visible:ring-2
    focus-visible:outline-none focus-visible:ring-offset-2` matching
    the v1.4.33 sidebar treatment.
  - Mobile top-bar trigger had `focus:outline-none` without a
    `focus-visible` replacement. Same treatment applied.

Tap-target floor (WCAG 2.5.5):
  - Input / NativeSelect / Select / DateTimeInput were locked to
    h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
    mobile clears the 44 px floor while desktop keeps the 40 px norm
    that the Button primitive already converged on in v1.4.33.

Admin chip-strip auto-scroll-into-view:
  - The admin shell shipped 13 chips but lacked the
    `scrollIntoView({inline: 'center'})` effect the settings shell
    got in v1.4.33. Admins tapping a chip past the right edge landed
    on the new page with the strip scrolled to the left and the
    active chip off-screen. Copied the settings-shell pattern verbatim.

List-page subtitles on mobile:
  - Measurements / Mood / Medications pages hid their descriptive
    subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
    the subtitle on mobile because it doubles as contextual help.
    Dropped the hide rule; the H1 still anchors the visual.

Insights empty-state CTA vocabulary:
  - `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
    `TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
    `MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
    empty-state CTAs emitted those mismatched tokens and the dialog
    silently failed to open. Replaced with a single source of truth
    derived from `MEASUREMENT_TYPES` plus a normalisation map for
    legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
    New unit test walks every `?add=` token emitted under
    `src/app/insights/**` and asserts it resolves to a real form type.
2026-05-17 00:34:00 +02:00

184 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
Activity,
Home,
Lightbulb,
MoreHorizontal,
Pill,
Target,
Trophy,
Waves,
} 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";
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 },
];
export function BottomNav() {
const pathname = usePathname();
const { t } = useTranslations();
const [moreOpen, setMoreOpen] = useState(false);
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 (
<>
{/* v1.4.33 F14 — bar height and safe-area defence so the bar can
never overlap the last viewport line of scrollable page content.
- `min-h-16` floors the bar at 64px even if the inner flex row
collapses (e.g. a future variant without one of the icons).
- `pb-[env(safe-area-inset-bottom)]` reserves the home-indicator
inset on iOS / iPadOS PWA installs.
- `bg-card` (solid, not the previous `bg-card/80`) plus
`border-t` give the bar a clean visual edge so any page
content that scrolls under the bar is properly occluded
rather than bleeding through a translucent surface. Page-
level pb-* padding owners (auth-shell + per-page shells)
still reserve room above the bar; this change hardens the
bar itself so the visual contract holds even when a page
shell forgets the bottom-padding contract.
*/}
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 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.
//
// v1.4.34 IW-G — keyboard users get a visible ring on
// focus-visible, matching the sidebar treatment from
// v1.4.33. Without it the bottom-nav had no focus
// indicator at all on Tab navigation.
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
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>
</>
);
}