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

@@ -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} />
)}