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

@@ -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":