Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions frontend/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export interface ModalProps {
*/
title?: React.ReactNode;

/** Accessible name used when the dialog has no visible title. */
ariaLabel?: string;

/**
* Optional description text displayed under the title.
*/
Expand Down Expand Up @@ -52,6 +55,7 @@ export function Modal({
isOpen,
onClose,
title,
ariaLabel,
description,
children,
footer,
Expand All @@ -74,14 +78,26 @@ export function Modal({

// Accessible Focus Trap
if (e.key === "Tab" && modalRef.current) {
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements.length) return;

const focusableElements = Array.from(
modalRef.current.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href]:not([aria-disabled="true"]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([aria-disabled="true"])'
)
).filter((element) => !element.closest('[hidden], [aria-hidden="true"]'));
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

if (!firstElement || !lastElement) {
e.preventDefault();
modalRef.current.focus();
return;
}

if (!modalRef.current.contains(document.activeElement)) {
e.preventDefault();
(e.shiftKey ? lastElement : firstElement).focus();
return;
}

if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
Expand All @@ -108,7 +124,7 @@ export function Modal({
requestAnimationFrame(() => {
if (modalRef.current) {
const focusable = modalRef.current.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
'button:not([disabled]), [href]:not([aria-disabled="true"]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([aria-disabled="true"])'
);
focusable ? focusable.focus() : modalRef.current.focus();
}
Expand Down Expand Up @@ -146,6 +162,7 @@ export function Modal({
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-label={!title ? ariaLabel ?? "Dialog" : undefined}
aria-describedby={description ? descId : undefined}
tabIndex={-1}
className={`relative z-10 w-full ${maxWidth} rounded-2xl border border-gray-100 bg-white p-6 shadow-2xl transition-all outline-none dark:border-gray-700 dark:bg-gray-800`}
Expand Down
26 changes: 26 additions & 0 deletions frontend/components/ui/ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,32 @@ describe("UI Primitives", () => {
});

describe("Modal", () => {
it("names an untitled dialog and keeps keyboard focus inside", async () => {
render(
<>
<button type="button">Outside action</button>
<Modal isOpen onClose={jest.fn()} ariaLabel="Authentication dialog">
<button type="button">Continue</button>
</Modal>
</>
);

const dialog = screen.getByRole("dialog", { name: "Authentication dialog" });
const closeButton = screen.getByRole("button", { name: "Close dialog" });
const continueButton = screen.getByRole("button", { name: "Continue" });
expect(dialog).toHaveAttribute("aria-modal", "true");
await waitFor(() => expect(closeButton).toHaveFocus());

fireEvent.keyDown(closeButton, { key: "Tab", shiftKey: true });
expect(continueButton).toHaveFocus();
fireEvent.keyDown(continueButton, { key: "Tab" });
expect(closeButton).toHaveFocus();

screen.getByRole("button", { name: "Outside action" }).focus();
fireEvent.keyDown(screen.getByRole("button", { name: "Outside action" }), { key: "Tab" });
expect(closeButton).toHaveFocus();
});

it("restores focus to the trigger after closing", () => {
function ModalHarness() {
const [open, setOpen] = React.useState(false);
Expand Down