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