mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user