mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user