mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Lift the bottom-bar center capture action into a proper floating-action button: a 56px target raised out of the bar with a card collar ring and a stronger shadow, so it reads as the primary CTA rather than a fifth flush tab. Desktop sidebar nav is unaffected. Unify the selectable controls across the mood logging surface onto one design language: the 5 mood faces, the tag tiles, the rated-factor steps and the GLP-1 chips now share a single selected-state treatment (border-primary, bg-primary/15, primary text, single border) and a consistent rounding family. Match the More-hub row height to the capture tiles so equivalent tappable rows share one min-height at the same tier. Give the demoted doctor-report export card a primary-tinted icon to match its peer cards — the demotion comes from position and size, not a muted icon. Drop the doubled heading atop the mood sheet by demoting the hero question to a field label so the sheet title is the single primary heading. Reword the capture mood option description to describe the action instead of echoing the hero question. (cherry picked from commit 1296f151f1c9a7c95ff917e3d69a5c0d369ba210)
222 lines
8.9 KiB
TypeScript
222 lines
8.9 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Activity,
|
||
Bell,
|
||
Dumbbell,
|
||
Home,
|
||
Lightbulb,
|
||
MoreHorizontal,
|
||
Pill,
|
||
Plus,
|
||
Settings,
|
||
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 { CapturePicker } from "@/components/layout/capture-picker";
|
||
import { cn } from "@/lib/utils";
|
||
import { useTranslations } from "@/lib/i18n/context";
|
||
|
||
interface NavLink {
|
||
href: string;
|
||
tKey: string;
|
||
icon: typeof Home;
|
||
}
|
||
|
||
// v1.12.x — iOS-parity bottom bar (additive middle-path).
|
||
//
|
||
// The mobile bar is a 5-slot shape: Home · Meds · Log(center) ·
|
||
// Insights · More — mirroring the native client. The center slot is a
|
||
// capture ACTION (not a destination): it opens a picker that routes to
|
||
// the existing measurement / medication / mood quick-entry surfaces
|
||
// (see `<CapturePicker>`).
|
||
//
|
||
// PRIMARY_LEFT / PRIMARY_RIGHT are the two anchor pairs that flank the
|
||
// center action. The previous strip (Home · Measurements · Mood ·
|
||
// Medications · Insights) carried Measurements and Mood inline; those
|
||
// stay fully reachable — through the center capture picker AND the More
|
||
// hub below — so nothing is orphaned. On desktop the sidebar still
|
||
// lists every destination.
|
||
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
|
||
{ href: "/", tKey: "nav.dashboard", icon: Home },
|
||
{ href: "/medications", tKey: "nav.medications", icon: Pill },
|
||
];
|
||
|
||
const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
|
||
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
|
||
];
|
||
|
||
// The "More" hub — a real hub of the remaining top-level destinations,
|
||
// not just an overflow bucket. Measurements and Mood live here (they
|
||
// left the always-visible strip when the center capture action took the
|
||
// middle slot), alongside Workouts, Achievements, Notifications and
|
||
// Settings. Every entry is an existing top-level route.
|
||
const MORE_HUB: ReadonlyArray<NavLink> = [
|
||
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
|
||
{ href: "/mood", tKey: "nav.mood", icon: Waves },
|
||
{ href: "/insights/workouts", tKey: "nav.workouts", icon: Dumbbell },
|
||
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
|
||
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
|
||
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
|
||
];
|
||
|
||
export function BottomNav() {
|
||
const pathname = usePathname();
|
||
const { t } = useTranslations();
|
||
const [moreOpen, setMoreOpen] = useState(false);
|
||
const [captureOpen, setCaptureOpen] = 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 the hub.
|
||
const moreActive = MORE_HUB.some((item) => isActiveLink(item.href));
|
||
|
||
function renderPrimary(item: NavLink) {
|
||
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 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>
|
||
);
|
||
}
|
||
|
||
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; `pb-[env(safe-area-inset-bottom)]`
|
||
reserves the home-indicator inset on iOS / iPadOS PWA installs;
|
||
`bg-card` + `border-t` give the bar a clean visual edge so page
|
||
content that scrolls under the bar is occluded, not bleeding
|
||
through. */}
|
||
<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_LEFT.map(renderPrimary)}
|
||
|
||
{/* Center capture action — a labeled button (NOT a link) that
|
||
opens the capture picker. Elevated as a Floating-Action-
|
||
Button: it sits larger than the flanking tabs (h-14 = 56px,
|
||
well above the 44px tap-target floor), lifts above the bar
|
||
with a negative top margin, and carries a filled-primary
|
||
collar (a `bg-card` ring that punches it out of the bar) plus
|
||
a stronger shadow so it reads as the bar's primary CTA rather
|
||
than a fifth flush tab. */}
|
||
<div className="flex flex-1 items-center justify-center">
|
||
<button
|
||
type="button"
|
||
aria-label={t("nav.capture.title")}
|
||
aria-haspopup="dialog"
|
||
aria-expanded={captureOpen}
|
||
onClick={() => setCaptureOpen(true)}
|
||
data-testid="bottom-nav-capture"
|
||
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||
>
|
||
<Plus className="h-7 w-7" />
|
||
</button>
|
||
</div>
|
||
|
||
{PRIMARY_RIGHT.map(renderPrimary)}
|
||
|
||
<button
|
||
type="button"
|
||
aria-label={t("nav.more")}
|
||
aria-haspopup="dialog"
|
||
aria-expanded={moreOpen}
|
||
aria-current={moreActive ? "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",
|
||
moreActive
|
||
? "text-primary"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
data-testid="bottom-nav-more"
|
||
>
|
||
<MoreHorizontal className="h-5 w-5" />
|
||
{moreActive && (
|
||
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<CapturePicker open={captureOpen} onOpenChange={setCaptureOpen} />
|
||
|
||
<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-2 gap-2 px-4 pb-4">
|
||
{MORE_HUB.map((item) => {
|
||
const isActive = isActiveLink(item.href);
|
||
return (
|
||
<Link
|
||
key={item.href}
|
||
href={item.href}
|
||
onClick={() => setMoreOpen(false)}
|
||
aria-current={isActive ? "page" : undefined}
|
||
// v1.12 — `min-h-14` matches the capture-picker tiles so
|
||
// the equivalent tappable rows share one height at this
|
||
// tier (and the larger row is a more comfortable target).
|
||
className={cn(
|
||
"border-border flex min-h-14 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>
|
||
</>
|
||
);
|
||
}
|