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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user