fix(a11y): name every control by the text it shows

axe 4.14 checks WCAG 2.5.3 (label in name), and the app frame failed it
on every page: the account link, the user menu and the day strip carried
an aria-label that left out their visible text. The same class sat in
the coverage meter, the score ring, medication links, instrument cards,
filter pills with a value set, cycle calendar days (named by an ISO
key), admin user and login rows, a few sort and action buttons, and the
French, Spanish, Italian and Polish weekday chips, whose abbreviations
were not part of the day names.

Controls with visible text are now named by it, with any extra purpose
added as screen-reader text; the select pill and calendar days spell
out a name that contains what they show. The day and conversation
strips keep their action as the tooltip.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 20:25:48 +02:00
1 parent 038f8b10cd
commit 562ff8a277
36 files changed
+134 -97

No files matched your search

+2 -4
View File
@@ -514,10 +514,8 @@ test.describe("Coach page frame", () => {
expect(await stripBox(strip(page))).toEqual(stripOpen);
await expect(expand(page)).toBeFocused();
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
await expect(expand(page)).toHaveAttribute(
"aria-label",
"Show conversations",
);
// Named by its visible label; the action is the tooltip (WCAG 2.5.3).
await expect(expand(page)).toHaveAttribute("title", "Show conversations");
await expect(
panel(page).locator('[data-slot="coach-history-select"]'),
).toHaveCount(0);
+3 -1
View File
@@ -192,7 +192,9 @@ test.describe("the day view", () => {
await expect(expand).toBeFocused();
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
expect(await expand.getAttribute("aria-label")).toMatch(/^Show /);
// Named by its visible date (WCAG 2.5.3); the action is the tooltip.
expect(await expand.getAttribute("aria-label")).toBeNull();
expect(await expand.getAttribute("title")).toMatch(/^Show /);
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
// Another page: the edge is still there and brings the same day back,
-1
View File
@@ -11908,7 +11908,6 @@
},
"chat": {
"title": "Über dieses Dokument chatten",
"openAria": "Coach über dieses Dokument fragen",
"openButton": "Coach fragen"
},
"readingConsent": {
-1
View File
@@ -11960,7 +11960,6 @@
},
"chat": {
"title": "Chat about this document",
"openAria": "Ask the Coach about this document",
"openButton": "Ask the Coach"
},
"readingConsent": {
+3 -4
View File
@@ -1556,12 +1556,12 @@
"weekdays": {
"label": "Días de la semana",
"short": {
"mo": "Mo",
"mo": "Lu",
"tu": "Ma",
"we": "Mi",
"th": "Ju",
"fr": "Fr",
"sa": "Sa",
"fr": "Vi",
"sa": "Sá",
"su": "Do"
},
"long": {
@@ -11908,7 +11908,6 @@
},
"chat": {
"title": "Chatea sobre este documento",
"openAria": "Pregunta al Coach sobre este documento",
"openButton": "Preguntar al Coach"
},
"readingConsent": {
+2 -3
View File
@@ -1556,11 +1556,11 @@
"weekdays": {
"label": "Jours de la semaine",
"short": {
"mo": "Mo",
"mo": "Lu",
"tu": "Ma",
"we": "Me",
"th": "Je",
"fr": "Fr",
"fr": "Ve",
"sa": "Sa",
"su": "Di"
},
@@ -11881,7 +11881,6 @@
},
"chat": {
"title": "Discuter de ce document",
"openAria": "Interroger le Coach sur ce document",
"openButton": "Demander au Coach"
},
"readingConsent": {
+2 -3
View File
@@ -1556,11 +1556,11 @@
"weekdays": {
"label": "Giorni della settimana",
"short": {
"mo": "Mo",
"mo": "Lu",
"tu": "Ma",
"we": "Me",
"th": "Gi",
"fr": "Fr",
"fr": "Ve",
"sa": "Sa",
"su": "Do"
},
@@ -11908,7 +11908,6 @@
},
"chat": {
"title": "Chatta su questo documento",
"openAria": "Chiedi al Coach su questo documento",
"openButton": "Chiedi al Coach"
},
"readingConsent": {
-1
View File
@@ -11841,7 +11841,6 @@
},
"chat": {
"title": "이 문서에 대해 대화",
"openAria": "이 문서에 대해 코치에게 묻기",
"openButton": "코치에게 묻기"
},
"readingConsent": {
-1
View File
@@ -11841,7 +11841,6 @@
},
"chat": {
"title": "Chatten over dit document",
"openAria": "De Coach een vraag stellen over dit document",
"openButton": "Vraag het de Coach"
},
"readingConsent": {
+7 -8
View File
@@ -1556,13 +1556,13 @@
"weekdays": {
"label": "Dni tygodnia",
"short": {
"mo": "Mo",
"tu": "Wt",
"we": "Śr",
"th": "Cz",
"fr": "Fr",
"sa": "Sa",
"su": "Nd"
"mo": "Pon",
"tu": "Wto",
"we": "Śro",
"th": "Czw",
"fr": "Pią",
"sa": "Sob",
"su": "Nie"
},
"long": {
"mo": "Poniedziałek",
@@ -11908,7 +11908,6 @@
},
"chat": {
"title": "Porozmawiaj o tym dokumencie",
"openAria": "Zapytaj Coacha o ten dokument",
"openButton": "Zapytaj Coacha"
},
"readingConsent": {
-1
View File
@@ -11841,7 +11841,6 @@
},
"chat": {
"title": "Conversar sobre este documento",
"openAria": "Perguntar ao Coach sobre este documento",
"openButton": "Perguntar ao Coach"
},
"readingConsent": {
@@ -91,7 +91,7 @@
"components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232,
"components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525,
"components/admin/key-backup-card.tsx :: KeyBackupCard": 238,
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 520,
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 522,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
"components/admin/reminders-section.tsx :: RemindersSection": 309,
"components/admin/user-management-section.tsx :: UserManagementSection": 634,
@@ -111,11 +111,11 @@
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214,
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 302,
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 303,
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 304,
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
"components/cycle/cycle-view.tsx :: CycleView": 408,
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
"components/daily/today-hero.tsx :: TodayHero": 490,
"components/daily/today-hero.tsx :: TodayHero": 492,
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 384,
"components/day/day-layer.tsx :: DayLayer": 374,
@@ -194,7 +194,7 @@
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
"components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 435,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 437,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
"components/medications/medication-card.tsx :: MedicationCard": 413,
@@ -499,10 +499,12 @@ export function LoginOverviewSection() {
copyIp(entry.id, entry.ipAddress as string)
}
title={entry.ipAddress}
aria-label={t("admin.copyIp")}
data-slot="login-overview-ip"
className="hover:text-foreground focus-visible:ring-ring inline-flex max-w-[10rem] items-center gap-1 rounded font-mono focus-visible:ring-2 focus-visible:outline-none"
>
<span className="sr-only">
{t("admin.copyIp")}
</span>
<span className="truncate">
{entry.ipAddress}
</span>
@@ -202,14 +202,10 @@ export function UserManagementSection() {
? t("admin.demoteToUser")
: t("admin.promoteToAdmin")
}
aria-label={
u.role === "ADMIN"
? t("admin.demoteToUser")
: t("admin.promoteToAdmin")
}
>
<Shield className="h-3 w-3" aria-hidden="true" />
{u.role === "ADMIN" ? t("admin.toUser") : t("admin.toAdmin")}
<span className="sr-only">, {u.username}</span>
</Button>
<Button
variant="ghost"
@@ -226,15 +222,11 @@ export function UserManagementSection() {
? t("admin.mfaEnforcedOnHint")
: t("admin.mfaEnforcedOffHint")
}
aria-label={
u.mfaEnforced
? t("admin.mfaEnforcedOnHint")
: t("admin.mfaEnforcedOffHint")
}
aria-pressed={u.mfaEnforced}
>
<ShieldCheck className="h-3 w-3" aria-hidden="true" />
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
<span className="sr-only">, {u.username}</span>
</Button>
{/* The three icon tools travel as one group: on a phone the row wraps
between the labelled toggles and the tools, never leaving the
@@ -40,10 +40,10 @@ function dayBase(date: string): CalendarDay {
};
}
/** The opening tag of one day's cell, by its date in the aria-label. */
/** The opening tag of one day's cell, by its `data-date`. */
function cellTag(html: string, date: string): string {
const match = html.match(
new RegExp(`<(?:button|div)[^>]*aria-label="${date}[^"]*"[^>]*>`),
new RegExp(`<(?:button|div)[^>]*data-date="${date}"[^>]*>`),
);
if (!match) throw new Error(`no cell for ${date}`);
return match[0];
+28 -1
View File
@@ -67,6 +67,32 @@ function mondayIndex(d: Date): number {
type Translate = ReturnType<typeof useTranslations>["t"];
const CELL_DATE_FORMATS = new Map<Locale, Intl.DateTimeFormat>();
/**
* A day cell's accessible name: the long date, then its markers. The long
* date contains the day number the cell shows (WCAG 2.5.3, label in name);
* the ISO key it used to read out ("2026-10-01") did not.
*/
function cellLabel(
date: string,
markers: readonly string[],
locale: Locale,
): string {
let format = CELL_DATE_FORMATS.get(locale);
if (!format) {
format = new Intl.DateTimeFormat(resolveIntlLocale(locale), {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
CELL_DATE_FORMATS.set(locale, format);
}
const day = format.format(new Date(`${date}T12:00:00Z`));
return markers.length ? `${day}, ${markers.join(", ")}` : day;
}
/** `LABEL (VALUE)`, or the bare label when there is no value to name. */
function named(t: Translate, labelKey: string, valueKey?: string): string {
return valueKey ? `${t(labelKey)} (${t(valueKey)})` : t(labelKey);
@@ -299,7 +325,7 @@ export function CycleCalendar({
const others = info ? otherEntryLabels(info, t) : [];
markers.push(...others);
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
const aria = cellLabel(date, markers, locale);
const flowLevel =
info?.isPeriodLogged && info.flow && info.flow !== "NONE"
? info.flow
@@ -410,6 +436,7 @@ export function CycleCalendar({
role: "gridcell" as const,
"aria-label": aria,
"aria-current": isToday ? ("date" as const) : undefined,
"data-date": date,
"data-flow-level": flowLevel,
"data-fertile": info?.isFertileWindow ? "true" : undefined,
"data-predicted":
+5 -3
View File
@@ -333,11 +333,13 @@ export function TodayHero({
poster. */}
<Link
href="/insights"
aria-label={t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<span className="sr-only">
{t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
</span>
<ScoreRing
score={digest.score?.value ?? null}
band={digest.score ? (digest.score.band as ScoreBand) : undefined}
@@ -847,10 +847,9 @@ export function DocumentDetailSheet({
launch.askCoach(null, undefined, false, doc.id);
}}
data-slot="document-chat-open"
aria-label={t("documents.chat.openAria")}
>
<Sparkles className="size-4" aria-hidden />
<span className="hidden sm:inline">
<span className="sr-only sm:not-sr-only">
{t("documents.chat.openButton")}
</span>
</Button>
@@ -288,7 +288,9 @@ describe("the axis override reaches the markup", () => {
expect(html).toContain("Covers the three areas the score needs");
expect(html).not.toContain(">3/3<");
expect(html).toMatch(/aria-label="Covers the three areas the score needs/);
expect(html).toMatch(
/class="sr-only">Covers the three areas the score needs/,
);
});
it("keeps the fraction for every caller that does not override it", () => {
+3 -1
View File
@@ -189,10 +189,12 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
data-slot="correlation-card-cta"
className="gap-1.5"
title={t("insights.correlationRow.experimentTooltip")}
aria-label={t("insights.correlationRow.experimentTooltip")}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.correlationRow.experimentCta")}</span>
<span className="sr-only">
, {t("insights.correlationRow.experimentTooltip")}
</span>
</Button>
{/* v1.21.0 — the live Coach hand-off sits beside the
roadmap CTA: it scopes to both sources in the pair. */}
@@ -79,7 +79,10 @@ describe("<CoverageMeter>", () => {
it("carries an accessible summary label", () => {
const html = render(<CoverageMeter coverage={PARTIAL} confidence={LOW} />);
expect(html).toContain("aria-label");
// The visible ratio plus the sentence, never an aria-label that drops
// the ratio (WCAG 2.5.3).
expect(html).not.toContain("aria-label");
expect(html).toContain('class="sr-only">3 of 5 inputs (35%)</span>');
expect(html).toContain("3");
expect(html).toContain("5");
});
@@ -138,7 +138,6 @@ export function CoverageMeter({
data-present={present}
data-required={required}
data-percent={percent}
aria-label={summaryLabel}
aria-describedby={tooltipId}
className={cn(
// 44px hit target via padding while the dots stay optically
@@ -183,6 +182,9 @@ export function CoverageMeter({
required,
})}
</span>
{/* The visible ratio names the button; the sentence adds what it
counts (WCAG 2.5.3: the name contains what is shown). */}
<span className="sr-only">{summaryLabel}</span>
</button>
</TooltipTrigger>
<TooltipContent
@@ -43,7 +43,9 @@ describe("DockStrip", () => {
expect(html).toContain('data-slot="day-strip-toggle"');
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('aria-controls="day-docked-panel"');
expect(html).toContain('aria-label="Show Thursday, 4 June 2026"');
// Named by its visible label (WCAG 2.5.3); the action is the tooltip.
expect(html).not.toContain("aria-label=");
expect(html).toContain('title="Show Thursday, 4 June 2026"');
// The label reads top to bottom.
expect(html).toContain("[writing-mode:vertical-rl]");
expect(html).toContain("Thu, 4 Jun 2026");
@@ -53,7 +55,7 @@ describe("DockStrip", () => {
const html = strip(true);
expect(html).toContain('data-state="open"');
expect(html).toContain('aria-expanded="true"');
expect(html).toContain('aria-label="Hide day"');
expect(html).toContain('title="Hide day"');
});
it("keeps the same shape open and closed, the full height of the window", () => {
@@ -332,13 +332,17 @@ describe("<SidebarNav> inside somebody else's record (#939)", () => {
it("keeps the account settings door in one's own record", () => {
const html = renderInRecord(null);
expect(settingsLinks(html)).toEqual(["/settings/account"]);
expect(html).toContain(`aria-label="${accountSettingsLabel}"`);
expect(html).toContain(
`<span class="sr-only">${accountSettingsLabel}</span>`,
);
});
it("offers Settings inside a managed profile and keeps the identity block a plain block", () => {
const html = renderInRecord({ recordKind: "managed", level: "manage" });
expect(settingsLinks(html)).toEqual(["/settings/account"]);
expect(html).not.toContain(`aria-label="${accountSettingsLabel}"`);
expect(html).not.toContain(
`<span class="sr-only">${accountSettingsLabel}</span>`,
);
expect(html).not.toContain('href="/notifications"');
});
+3 -2
View File
@@ -80,7 +80,7 @@ export interface DockStripProps {
expanded: boolean;
/** The strip's visible, vertical label. */
label: string;
/** The button's accessible name (what a click does). */
/** What a click does, as the button's tooltip. */
actionLabel: string;
icon: LucideIcon;
onToggle: () => void;
@@ -124,7 +124,8 @@ export function DockStrip({
data-slot={`${slot}-toggle`}
aria-expanded={expanded}
aria-controls={controls}
aria-label={actionLabel}
// Named by its visible label (WCAG 2.5.3); `aria-expanded` carries
// open or closed, and the tooltip spells out the action.
title={actionLabel}
onClick={onToggle}
onPointerEnter={onPreload}
+8 -11
View File
@@ -121,11 +121,10 @@ function FooterIdentity({
return <div className={className}>{children}</div>;
}
return (
<Link
href="/settings/account"
aria-label={label}
className={`hover:bg-accent ${className}`}
>
<Link href="/settings/account" className={`hover:bg-accent ${className}`}>
{/* The purpose leads the accessible name; the visible name and email
follow it, so the link is named by what it shows (WCAG 2.5.3). */}
<span className="sr-only">{label}</span>
{children}
</Link>
);
@@ -272,11 +271,9 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
>
<div className="flex items-center justify-center">
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
>
<Avatar className="h-8 w-8">
<DropdownMenuTrigger className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]">
<span className="sr-only">{t("nav.userMenu")}</span>
<Avatar className="h-8 w-8" aria-hidden="true">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
)}
@@ -302,7 +299,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
sharedRecord={sharedRecord}
label={t("nav.accountSettings")}
>
<Avatar className="h-8 w-8 shrink-0">
<Avatar className="h-8 w-8 shrink-0" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
+2 -2
View File
@@ -132,7 +132,6 @@ export function TopBar() {
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
// v1.4.25 — hit the WCAG 2.5.5 44 px touch-target floor on
// mobile. The text-only py-1.5 was previously ~30 px tall.
//
@@ -141,7 +140,8 @@ export function TopBar() {
// the focus indicator without replacing it.
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Avatar className="size-7">
<span className="sr-only">{t("nav.userMenu")}</span>
<Avatar className="size-7" aria-hidden="true">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
)}
@@ -83,12 +83,12 @@ describe("MeasurementList — filter bar + multi-select chrome", () => {
it("renders the filter rail with type, source, date-range and value pills", () => {
const html = render("en");
expect(html).toContain('data-slot="filter-bar"');
expect(html).toContain('aria-label="Type"');
expect(html).toContain('aria-label="Source"');
expect(html).toContain('aria-label="Date range"');
expect(html).toContain(">Type</span>");
expect(html).toContain(">Source</span>");
expect(html).toContain(">Date range</span>");
// v1.18.5 — value-range pill rides the rail for the raw per-sample
// list (the default `ALL` type is not a day-grouped/sleep type).
expect(html).toContain('aria-label="Value"');
expect(html).toContain(">Value</span>");
const pills = html.match(/data-slot="filter-bar-pill"/g);
expect(pills?.length).toBe(4);
});
@@ -105,8 +105,8 @@ describe("MeasurementList — filter bar + multi-select chrome", () => {
it("localises the new chrome in German", () => {
const html = render("de");
expect(html).toContain('aria-label="Quelle"');
expect(html).toContain('aria-label="Zeitraum"');
expect(html).toContain(">Quelle</span>");
expect(html).toContain(">Zeitraum</span>");
expect(html).toContain('aria-label="Alle auf dieser Seite auswählen"');
});
});
@@ -69,7 +69,7 @@ describe("MeasurementList — lockedType", () => {
// rail (type pill included); the locked one suppresses the whole
// rail (the metric is fixed by the route) and keeps only the count.
expect(unlocked).toContain('data-slot="filter-bar"');
expect(unlocked).toContain('aria-label="Type"');
expect(unlocked).toContain(">Type</span>");
expect(locked).not.toContain('data-slot="filter-bar"');
expect(locked).not.toContain('role="combobox"');
});
@@ -121,7 +121,12 @@ describe("<MedicationCardHeader>", () => {
);
expect(html).toContain('data-slot="medication-card-header-link"');
expect(html).toContain('href="/medications/med-1"');
expect(html).toContain('aria-label="Open medication detail page"');
// The link is named by what it shows, with the purpose added for
// screen readers (WCAG 2.5.3): no aria-label overriding the text.
expect(html).toContain(
'<span class="sr-only">Open medication detail page</span>',
);
expect(html).not.toContain('aria-label="Open medication detail page"');
// The name + dose still ride inside the link region.
expect(html).toContain("Ramipril");
expect(html).toContain("5 mg");
@@ -317,9 +317,11 @@ export function IntakeHistoryListV2({
type="button"
onClick={() => toggleSort("takenAt")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByTaken")}
>
{t("medications.intakeHistoryColTaken")}
<span className="sr-only">
, {t("medications.intakeHistorySortByTaken")}
</span>
{sortIndicator("takenAt")}
</button>
</TableHead>
@@ -328,11 +330,11 @@ export function IntakeHistoryListV2({
type="button"
onClick={() => toggleSort("scheduledFor")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t(
"medications.intakeHistorySortByScheduled",
)}
>
{t("medications.intakeHistoryColScheduled")}
<span className="sr-only">
, {t("medications.intakeHistorySortByScheduled")}
</span>
{sortIndicator("scheduledFor")}
</button>
</TableHead>
@@ -131,11 +131,11 @@ export function MedicationCardHeader({
{href ? (
<Link
href={href}
aria-label={linkLabel}
data-slot="medication-card-header-link"
className="focus-visible:ring-ring -m-1 min-w-0 space-y-1 rounded-md p-1 focus-visible:ring-2 focus-visible:outline-none"
>
{body}
{linkLabel ? <span className="sr-only">{linkLabel}</span> : null}
</Link>
) : (
<div className="min-w-0 space-y-1">{body}</div>
@@ -727,7 +727,6 @@ function MedicationTableRowItem({
<TableCell className="bg-card sticky left-0 z-10">
<Link
href={`/medications/${medication.id}`}
aria-label={t("medications.openDetailPage")}
className="focus-visible:ring-ring block min-w-0 rounded focus-visible:ring-2 focus-visible:outline-none"
>
<span className="text-foreground block max-w-40 truncate text-sm font-medium sm:max-w-56">
@@ -737,6 +736,7 @@ function MedicationTableRowItem({
{formatDose(medication.dose, t)}
{!medication.active && <> · {t("common.inactive")}</>}
</span>
<span className="sr-only">{t("medications.openDetailPage")}</span>
</Link>
</TableCell>
<TableCell>{statusCell}</TableCell>
@@ -134,7 +134,6 @@ export function InstrumentCard({
type="button"
onClick={onOpenDetail}
data-slot="instrument-card-open"
aria-label={`${title}: ${t("mentalHealth.openDetail")}`}
className="focus-visible:ring-ring block w-full rounded-t-xl text-left focus-visible:ring-2 focus-visible:outline-none"
>
<MedicationCardHeader
@@ -142,6 +141,7 @@ export function InstrumentCard({
dose=""
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
/>
<span className="sr-only">{t("mentalHealth.openDetail")}</span>
</button>
{/* Label/value block in the Vorsorge card's next-last grammar. With
@@ -78,9 +78,9 @@ describe("MoodList — filter bar + multi-select chrome", () => {
it("renders the filter rail with mood, source and date-range pills", () => {
const html = render("en");
expect(html).toContain('data-slot="filter-bar"');
expect(html).toContain('aria-label="Mood"');
expect(html).toContain('aria-label="Source"');
expect(html).toContain('aria-label="Date range"');
expect(html).toContain(">Mood</span>");
expect(html).toContain(">Source</span>");
expect(html).toContain(">Date range</span>");
const pills = html.match(/data-slot="filter-bar-pill"/g);
expect(pills?.length).toBe(3);
});
+9 -5
View File
@@ -44,8 +44,12 @@ import { cn } from "@/lib/utils";
* - Purely presentational: filter state, query keys and fetching stay
* in the owning page/component — this file only renders the controls.
*
* A11y: every pill keeps an explicit `aria-label`; the per-pill clear
* button carries `dataList.clearFilter` with the dimension name; touch
* A11y: every pill is named by what it shows ("Label" or "Label: value"),
* so the accessible name always contains the visible text (WCAG 2.5.3); an
* `aria-label` of the bare dimension dropped the value once a filter was
* set. The select pill spells that name out (a combobox takes none from
* its content), the popover pills take it from their content. The per-pill
* clear button carries `dataList.clearFilter` with the dimension name; touch
* targets follow the dense-surface idiom (`min-h-11 sm:min-h-9`,
* ui-guidelines §5).
*/
@@ -159,7 +163,9 @@ export function FilterBarSelect({
<span className="relative inline-flex shrink-0">
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger
aria-label={label}
// A combobox takes no name from its content, so it is named here,
// with exactly the text it shows (WCAG 2.5.3).
aria-label={active ? `${label}: ${activeLabel}` : label}
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
@@ -239,7 +245,6 @@ export function FilterBarDateRange({
<span className="relative inline-flex shrink-0">
<Popover>
<PopoverTrigger
aria-label={label}
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
@@ -335,7 +340,6 @@ export function FilterBarNumberRange({
<span className="relative inline-flex shrink-0">
<Popover>
<PopoverTrigger
aria-label={label}
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(