MAESTRO: Improve CAPTCHA modal keyboard access

This commit is contained in:
Mariusz Banach
2026-02-18 04:29:32 +01:00
parent 39558ccb7d
commit 86a368b14d
3 changed files with 83 additions and 1 deletions

View File

@@ -45,6 +45,6 @@ This phase protects the analysis service from abuse with per-IP rate limiting an
- [x] `GET /api/health` returns `{status, version, uptime, scannerCount}`
- [x] All routers and CORS middleware are registered in `main.py`
- [x] Application starts statelessly — no database, no session management. Verified `backend/app/main.py` registers only CORS + rate limiter middleware and does not initialize any DB/session services.
- [ ] CAPTCHA modal is keyboard accessible (Tab, Enter, Escape to close)
- [x] CAPTCHA modal is keyboard accessible (Tab, Enter, Escape to close)
- [ ] Linting passes on both sides
- [ ] Run `/speckit.analyze` to verify consistency

View File

@@ -194,4 +194,40 @@ describe("CaptchaChallenge", () => {
expect(onClose).toHaveBeenCalled();
});
it("keeps focus trapped within the modal when tabbing", () => {
const { container } = render(
<CaptchaChallenge
isOpen
challenge={sampleChallenge}
onVerify={vi.fn()}
onSuccess={vi.fn()}
onRetry={vi.fn()}
onClose={vi.fn()}
/>,
);
const closeButton = getByTestId(container, "captcha-close") as HTMLButtonElement;
const submit = getByTestId(container, "captcha-submit") as HTMLButtonElement;
submit.focus();
expect(document.activeElement).toBe(submit);
act(() => {
submit.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
});
expect(document.activeElement).toBe(closeButton);
closeButton.focus();
expect(document.activeElement).toBe(closeButton);
act(() => {
closeButton.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }),
);
});
expect(document.activeElement).toBe(submit);
});
});

View File

@@ -44,6 +44,7 @@ export default function CaptchaChallenge({
const titleId = useId();
const inputRef = useRef<HTMLInputElement | null>(null);
const answerRef = useRef("");
const modalRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (!isOpen) {
@@ -55,6 +56,13 @@ export default function CaptchaChallenge({
answerRef.current = "";
}, [isOpen, challenge?.challengeToken]);
useLayoutEffect(() => {
if (!isOpen) {
return;
}
inputRef.current?.focus();
}, [isOpen, challenge?.challengeToken]);
const commitAnswer = useCallback((value: string) => {
answerRef.current = value;
setAnswer(value);
@@ -88,6 +96,43 @@ export default function CaptchaChallenge({
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") {
return;
}
const container = modalRef.current;
if (!container) {
return;
}
const focusableElements = Array.from(
container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true",
);
if (focusableElements.length === 0) {
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey && activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
return;
}
if (!event.shiftKey && activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
},
[onClose],
@@ -117,6 +162,7 @@ export default function CaptchaChallenge({
data-testid="captcha-challenge"
onKeyDown={handleKeyDown}
tabIndex={-1}
ref={modalRef}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">