mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|