a11y(settings): give update badge a 44 px hit target, focus ring + real aria-label

Extract the newer-version badge into an exported UpdateBadge
component. Renders as a 44 px-square anchor (or role=img span
fallback) with text-primary colour, focus-visible:ring-* on the
anchor branch, and aria-label/title carrying the latest tag so
keyboard + screen-reader users get the same signal sighted users
get from the icon. Drops the hardcoded text-dracula-purple in
favour of the semantic primary token, which already meets WCAG AA
on light + dark.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 13:01:39 +02:00
1 parent 42039372ef
commit 3e0eed8284
2 files changed
+89 -30

No files matched your search

@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { AboutSection } from "../about-section";
import { AboutSection, UpdateBadge } from "../about-section";
/**
* v1.4.36 W4f — AboutSection update-badge contract.
@@ -100,3 +100,37 @@ describe("<AboutSection> — update badge", () => {
expect(html).toContain("Sources &amp; docs");
});
});
describe("<UpdateBadge> — a11y contract", () => {
it("renders an anchor with aria-label, focus-visible ring, and 44 px hit target when a URL is known", () => {
const html = renderToStaticMarkup(
<UpdateBadge
latestTag="v1.4.99"
htmlUrl="https://example.test/releases/v1.4.99"
ariaLabel="A newer version is available: v1.4.99"
/>,
);
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
expect(html).toContain("focus-visible:ring-ring");
expect(html).toContain("focus-visible:ring-2");
expect(html).toContain("focus-visible:ring-offset-2");
expect(html).toContain("text-primary");
expect(html).toContain('href="https://example.test/releases/v1.4.99"');
});
it("falls back to a span with role=img + aria-label when no URL is known", () => {
const html = renderToStaticMarkup(
<UpdateBadge
latestTag="v1.4.99"
htmlUrl={null}
ariaLabel="A newer version is available: v1.4.99"
/>,
);
expect(html).toContain('role="img"');
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
});
});
+54 -29
View File
@@ -67,6 +67,51 @@ function writeLastCheckedISO(iso: string): void {
}
}
/**
* v1.4.36 QA H7 — keyboard- and touch-accessible update badge.
*
* Renders as a 44 px-square hit target (anchor when a release URL is
* known, span fallback otherwise) with a focus-visible ring, semantic
* `text-primary` colour, and a real `aria-label` describing the
* available version. Exported so the SSR test can pin the contract
* without driving the parent's auto-check effect.
*/
export function UpdateBadge({
latestTag,
htmlUrl,
ariaLabel,
}: {
latestTag: string;
htmlUrl: string | null;
ariaLabel: string;
}) {
if (htmlUrl) {
return (
<a
href={htmlUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaLabel}
title={ariaLabel}
className="text-primary focus-visible:ring-ring inline-flex min-h-11 min-w-11 items-center justify-center rounded-md hover:opacity-80 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<ArrowUpCircle className="h-4 w-4" />
<span className="sr-only">{latestTag}</span>
</a>
);
}
return (
<span
role="img"
aria-label={ariaLabel}
title={ariaLabel}
className="text-primary inline-flex min-h-11 min-w-11 items-center justify-center"
>
<ArrowUpCircle className="h-4 w-4" />
</span>
);
}
export function AboutSection() {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -185,35 +230,15 @@ export function AboutSection() {
the link copy on mobile). No bouncing, no badge
counter, no manual recheck button — the badge is
the entire surface. */}
{updateResult?.status === "newer_available" &&
(updateResult.html_url ? (
<a
href={updateResult.html_url}
target="_blank"
rel="noopener noreferrer"
aria-label={t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
title={t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
className="text-dracula-purple inline-flex items-baseline hover:opacity-80"
>
<ArrowUpCircle className="h-4 w-4 self-center" />
</a>
) : (
<span
aria-label={t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
title={t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
className="text-dracula-purple inline-flex items-baseline"
>
<ArrowUpCircle className="h-4 w-4 self-center" />
</span>
))}
{updateResult?.status === "newer_available" && (
<UpdateBadge
latestTag={updateResult.latest_tag}
htmlUrl={updateResult.html_url}
ariaLabel={t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
/>
)}
</div>
<div className="flex items-baseline gap-2">