mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce)
Multiple accessibility + visual defects on `<InsightsSectionNav>`: - No `aria-current` on the active tab — screen readers had no signal the active section had changed. - No `:focus-visible` ring — `transition-colors` only animated the background, leaving keyboard focus invisible against `bg-primary/10`. - Unconditional `scroll-smooth` ignored `prefers-reduced-motion`. - `bg-background/80` let the hero gradient bleed through. - `-mx-4` carried the bar past the safe horizontal-scroll boundary on 280 px Galaxy Fold; combined with the inner `overflow-x-auto` it produced a ghost scrollbar. - 112 px `scroll-mt-28` was ~2.5× the actual sticky-strip height (~40-44 px), pushing target headings well below the viewport top. - IntersectionObserver picked the last entry in the batch instead of the highest intersectionRatio, so the active pill jumped during fast scrolls. Add `aria-current="location"`, focus-visible ring, motion-reduce gate around `scrollIntoView`, swap `bg-background/80` for `/95 backdrop-blur`, drop the negative margin (the parent handles horizontal padding), hide the inner scrollbar, ratio-sort the observer entries, and tune `scroll-mt-28` → `scroll-mt-16` on all seven sections. Add `insights.navAriaLabel` (EN: "Skip to section", DE: "Zu Abschnitt springen"). Closes Design-H1, Design-H3, Code-MED-4, Design-LOW-4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2075d392b1
commit
a2e24b1240
3 files changed
+68
-27
No files matched your search
@@ -901,6 +901,7 @@
|
||||
"navMood": "Stimmung",
|
||||
"navMedication": "Medikamente",
|
||||
"navBmi": "BMI",
|
||||
"navAriaLabel": "Zu Abschnitt springen",
|
||||
"measurementsOverDays": "{count} Messungen über {days} Tage",
|
||||
"healthStatus": "Gesundheitsstatus",
|
||||
"noWeightData": "Keine Gewichtsdaten oder Größe nicht hinterlegt.",
|
||||
|
||||
@@ -901,6 +901,7 @@
|
||||
"navMood": "Mood",
|
||||
"navMedication": "Medication",
|
||||
"navBmi": "BMI",
|
||||
"navAriaLabel": "Skip to section",
|
||||
"measurementsOverDays": "{count} measurements over {days} days",
|
||||
"healthStatus": "Health Status",
|
||||
"noWeightData": "No weight data or height not set.",
|
||||
|
||||
+66
-27
@@ -1017,7 +1017,7 @@ export default function InsightsPage() {
|
||||
legacyPayload={advisor.payload?.legacyPayload ?? false}
|
||||
/>
|
||||
|
||||
<section id="section-general" className="scroll-mt-28 space-y-2">
|
||||
<section id="section-general" className="scroll-mt-16 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.generalStatusTitle")}
|
||||
@@ -1041,7 +1041,7 @@ export default function InsightsPage() {
|
||||
</section>
|
||||
|
||||
{/* Section 3: Blood pressure */}
|
||||
<section id="section-bp" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-bp" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.bloodPressureSectionTitle")}
|
||||
@@ -1255,7 +1255,7 @@ export default function InsightsPage() {
|
||||
</section>
|
||||
|
||||
{/* Section 4: Weight */}
|
||||
<section id="section-weight" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-weight" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.weightSectionTitle")}
|
||||
@@ -1425,7 +1425,7 @@ export default function InsightsPage() {
|
||||
</section>
|
||||
|
||||
{/* Section 5: Pulse */}
|
||||
<section id="section-pulse" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-pulse" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.pulseSectionTitle")}
|
||||
@@ -1460,7 +1460,7 @@ export default function InsightsPage() {
|
||||
|
||||
{/* Section: Mood */}
|
||||
{showMoodSection && (
|
||||
<section id="section-mood" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-mood" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.moodSectionTitle")}
|
||||
@@ -1488,7 +1488,7 @@ export default function InsightsPage() {
|
||||
)}
|
||||
|
||||
{/* Section 6: Medication Compliance */}
|
||||
<section id="section-meds" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-meds" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.medicationCompliance")}
|
||||
@@ -1589,7 +1589,7 @@ export default function InsightsPage() {
|
||||
</section>
|
||||
|
||||
{/* Section 7: BMI */}
|
||||
<section id="section-bmi" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-bmi" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.bmiSectionTitle")}
|
||||
@@ -1729,10 +1729,19 @@ function InsightsSectionNav() {
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id);
|
||||
}
|
||||
// v1.4.22 W5 reconcile (Code-MED-4) — pick the entry with the
|
||||
// highest intersectionRatio in the band rather than the
|
||||
// observer-supplied last entry. Three sections briefly
|
||||
// visible during a fast scroll otherwise made the active
|
||||
// pill jump to whichever one came last in the batch.
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||
const top = visible[0];
|
||||
if (top) {
|
||||
setActiveId((current) =>
|
||||
current === top.target.id ? current : top.target.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-30% 0px -60% 0px" },
|
||||
@@ -1748,26 +1757,56 @@ function InsightsSectionNav() {
|
||||
|
||||
function scrollTo(id: string) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||
if (!el) return;
|
||||
// v1.4.22 W5 reconcile (Design-H1) — gate smooth scrolling behind
|
||||
// `prefers-reduced-motion`; honour the user's OS-level pref.
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
el.scrollIntoView({
|
||||
behavior: prefersReducedMotion ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-background/80 sticky top-0 z-30 -mx-4 overflow-x-auto border-b px-4 py-2 backdrop-blur-sm md:-mx-6 md:px-6">
|
||||
// v1.4.22 W5 reconcile (Design-H1, Design-H3) — accessibility +
|
||||
// sticky-strip polish. Notable changes:
|
||||
// - aria-label so screen-reader landmark traversal hears the
|
||||
// rail's purpose ("Skip to section" / "Zu Abschnitt springen").
|
||||
// - bg-background/95 instead of /80 to kill the hero-glow bleed
|
||||
// during scroll.
|
||||
// - Drop the `-mx-4 / md:-mx-6` negative margin; the parent
|
||||
// container handles horizontal padding so 280px (Galaxy Fold)
|
||||
// doesn't get a ghost scrollbar.
|
||||
// - Hide the inner overflow's scrollbar so the sticky strip
|
||||
// reads as a single bar.
|
||||
<nav
|
||||
aria-label={t("insights.navAriaLabel")}
|
||||
className={cn(
|
||||
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
{SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => scrollTo(id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
activeId === id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(SECTION_LABEL_KEYS[id])}
|
||||
</button>
|
||||
))}
|
||||
{SECTION_IDS.map((id) => {
|
||||
const isActive = activeId === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => scrollTo(id)}
|
||||
aria-current={isActive ? "location" : undefined}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(SECTION_LABEL_KEYS[id])}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user