feat(a11y): WCAG 2.2 AA accessibility compliance (#209)

* feat(a11y): add i18n keys for ARIA labels and screen reader text

* fix(security): harden API against pentest findings

- Default TRUST_PROXY=false to prevent XFF rate limit bypass (PT-01)
- Return 400 instead of 500 on malformed JSON input (PT-03)
- Default MAX_PIPELINE_STEPS=20 to prevent DoS (PT-04)
- Validate clientJobId length (max 128) across all routes (PT-06)
- Add security headers to all reply.hijack() streaming responses (PT-07)
- Sanitize usernames in audit log to prevent stored XSS (PT-08)
- Block TRACE method with 405 response (PT-10)
- Add 429 RateLimited response to OpenAPI spec (PT-12)
- Default MAX_SVG_SIZE_MB=50 to limit SVGZ decompression (PT-13)
- Pin Dockerfile base images by digest
- Sanitize OIDC IdP error and sub claim in audit log
- Sync Docker compose/Dockerfile defaults with env.ts

* feat(a11y): convert all hardcoded aria-labels to i18n keys

Replace 49 hardcoded aria-label="..." strings across 25 files with
their corresponding t.a11y.* and t.common.* i18n references. Add
useTranslation import and hook call to 15 components that lacked it.
Zero hardcoded aria-labels remain in the codebase.

* feat(a11y): add aria-labels to icon-only buttons, aria-hidden on decorative icons, sr-only status text

* feat(a11y): add aria-live regions for processing status announcements

* feat(a11y): add skip-nav link, route announcer, main content landmark, and page h1 elements

* feat(a11y): add prefers-reduced-motion support, preserve functional spinners

* feat(a11y): add useFocusTrap hook for modal focus management

* feat(a11y): add focus trapping and dialog roles to all modals

* feat(a11y): add toggle switch roles, form labels, and error association

* fix(a11y): fix contrast failures, touch targets, and add nav landmark to sidebar

* fix(a11y): add role=switch to remaining toggle buttons found in verification sweep
This commit is contained in:
SnapOtter
2026-06-07 23:32:41 +08:00
committed by GitHub
parent ace41168bc
commit 6f276b4ef0
75 changed files with 614 additions and 183 deletions
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useRef } from "react";
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
export function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active: boolean) {
const returnFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active || !containerRef.current) return;
returnFocusRef.current = document.activeElement as HTMLElement;
const container = containerRef.current;
const getFocusableElements = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.closest("[aria-hidden='true']"),
);
const focusFirst = () => {
const elements = getFocusableElements();
if (elements.length > 0) {
elements[0].focus();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const elements = getFocusableElements();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
const observer = new MutationObserver(() => {
const elements = getFocusableElements();
if (elements.length > 0 && !container.contains(document.activeElement)) {
elements[0].focus();
}
});
observer.observe(container, { childList: true, subtree: true });
container.addEventListener("keydown", handleKeyDown);
requestAnimationFrame(focusFirst);
return () => {
container.removeEventListener("keydown", handleKeyDown);
observer.disconnect();
if (returnFocusRef.current && returnFocusRef.current.isConnected) {
returnFocusRef.current.focus();
}
};
}, [active, containerRef]);
}