fix(a11y): label per-reminder toggles and wire disclosure controls

The diabetes PATCH parsed its body without a size cap; bound it through
safeJson like the sibling routes. Label each reminder's enable/disable switch
by its own name span so screen readers hear the reminder rather than one
generic label repeated per row. Point the chronotype and illness disclosure
triggers at the regions they reveal via aria-controls.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 22:42:04 +02:00
1 parent 397423dddc
commit 9014203d8a
4 files changed
+22 -11

No files matched your search

+8 -7
View File
@@ -19,12 +19,13 @@
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -74,12 +75,12 @@ export const PATCH = apiHandler(async (req: Request) => {
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "diabetes.body.invalid_json");
}
// The body is a single boolean — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
+4 -1
View File
@@ -15,7 +15,7 @@
* per-day timeline and correlation card live. Retrospective-only copy — a
* journal, not a medical device, and it does not diagnose.
*/
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
@@ -245,10 +245,12 @@ function EpisodeGroup({
}) {
// Hooks must run unconditionally; bail on the empty render afterwards.
const [open, setOpen] = useState(false);
const gridId = useId();
if (parents.length === 0) return null;
const grid = (
<div
id={gridId}
className={cn(
view === "list" ? "space-y-2" : "grid gap-4 sm:grid-cols-2",
)}
@@ -275,6 +277,7 @@ function EpisodeGroup({
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={gridId}
className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium uppercase tracking-wide transition-colors"
>
<ChevronDown
@@ -103,6 +103,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
type="button"
onClick={() => setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced}
aria-controls="chronotype-advanced"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors"
>
{showAdvanced ? (
@@ -114,7 +115,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
</button>
{showAdvanced && (
<div className="space-y-2 border-t pt-3">
<div id="chronotype-advanced" className="space-y-2 border-t pt-3">
{chronotype.socialJetlagMinutes != null &&
(() => {
const { hours, minutes } = hoursMinutes(
@@ -100,7 +100,10 @@ export function VorsorgeSettings() {
key={reminder.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span className="min-w-0 truncate text-sm">
<span
id={`vorsorge-reminder-label-${reminder.id}`}
className="min-w-0 truncate text-sm"
>
{resolveLabel(reminder, t)}
</span>
<Switch
@@ -112,7 +115,10 @@ export function VorsorgeSettings() {
})
}
disabled={update.isPending}
aria-label={t("measurementReminders.enabledToggleAria")}
// L3 — label each row's Switch by its own name span so SR
// users hear the reminder name (plus the switch role's
// on/off state) instead of N identical generic labels.
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
/>
</div>
))}