mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: Improve CAPTCHA modal keyboard access
This commit is contained in:
@@ -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] `GET /api/health` returns `{status, version, uptime, scannerCount}`
|
||||||
- [x] All routers and CORS middleware are registered in `main.py`
|
- [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.
|
- [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
|
- [ ] Linting passes on both sides
|
||||||
- [ ] Run `/speckit.analyze` to verify consistency
|
- [ ] Run `/speckit.analyze` to verify consistency
|
||||||
|
|||||||
@@ -194,4 +194,40 @@ describe("CaptchaChallenge", () => {
|
|||||||
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function CaptchaChallenge({
|
|||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const answerRef = useRef("");
|
const answerRef = useRef("");
|
||||||
|
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -55,6 +56,13 @@ export default function CaptchaChallenge({
|
|||||||
answerRef.current = "";
|
answerRef.current = "";
|
||||||
}, [isOpen, challenge?.challengeToken]);
|
}, [isOpen, challenge?.challengeToken]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [isOpen, challenge?.challengeToken]);
|
||||||
|
|
||||||
const commitAnswer = useCallback((value: string) => {
|
const commitAnswer = useCallback((value: string) => {
|
||||||
answerRef.current = value;
|
answerRef.current = value;
|
||||||
setAnswer(value);
|
setAnswer(value);
|
||||||
@@ -88,6 +96,43 @@ export default function CaptchaChallenge({
|
|||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClose();
|
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],
|
[onClose],
|
||||||
@@ -117,6 +162,7 @@ export default function CaptchaChallenge({
|
|||||||
data-testid="captcha-challenge"
|
data-testid="captcha-challenge"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
ref={modalRef}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user