Skip to content
Merged
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
56 changes: 56 additions & 0 deletions src/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,60 @@ describe("Modal ", () => {
const proceedButton = screen.getByRole("button", { name: /^proceed$/i });
expect(proceedButton).toHaveFocus();
});

it("excludes hidden focusable elements from the focus trap", async () => {
const user = userEvent.setup();
const { container } = render(
<Modal
title="Test"
close={jest.fn()}
buttonRow={
<>
<button id="test-cancel">Cancel</button>
<button id="test-hidden" style={{ display: "none" }}>
Hidden
</button>
</>
}
>
Bare bones
</Modal>,
);

const closeButton = container.querySelector("button.p-modal__close");
const cancelButton = container.querySelector("button#test-cancel");

expect(closeButton).toHaveFocus();
await user.tab({ shift: true });
expect(cancelButton).toHaveFocus();

await user.tab();
expect(closeButton).toHaveFocus();
});

it("initially focuses the first visible focusable element when the close button is hidden", () => {
const style = document.createElement("style");
style.innerHTML = ".p-modal__close { display: none; }";
document.head.appendChild(style);

try {
const { container } = render(
<Modal
title="Test"
close={jest.fn()}
buttonRow={<button id="test-cancel">Cancel</button>}
>
Bare bones
</Modal>,
);

const closeButton = container.querySelector("button.p-modal__close");
const cancelButton = container.querySelector("button#test-cancel");

expect(closeButton).not.toHaveFocus();
expect(cancelButton).toHaveFocus();
} finally {
document.head.removeChild(style);
}
});
});
74 changes: 56 additions & 18 deletions src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,24 +67,46 @@

const modalRef: RefObject<HTMLDivElement> = useRef(null);
const closeButtonRef: RefObject<HTMLButtonElement> = useRef(null);
const handleTabKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
const focusableModalElements = modalRef.current.querySelectorAll(
focusableElementSelectors,
);
if (focusableModalElements.length > 0) {
const firstElement = focusableModalElements[0];
const lastElement =
focusableModalElements[focusableModalElements.length - 1];

if (!event.shiftKey && document.activeElement === lastElement) {
(firstElement as HTMLElement).focus();
event.preventDefault();
}

if (event.shiftKey && document.activeElement === firstElement) {
(lastElement as HTMLElement).focus();
return event.preventDefault();
// determines whether an element is visible by checking computed styles
// fails open: an element is only treated as hidden when getComputedStyle
// explicitly reports `display:none` or `visibility:hidden`
const isElementVisible = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
const style = window.getComputedStyle(current);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
current = current.parentElement;
}
return true;
};

const getVisibleFocusableElements = (): HTMLElement[] => {
if (!modalRef.current) {
return [];
}
return Array.from(
modalRef.current.querySelectorAll<HTMLElement>(focusableElementSelectors),
).filter(isElementVisible);
};

const handleTabKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
const focusableModalElements = getVisibleFocusableElements();
if (focusableModalElements.length === 0) {
return;
}
const firstElement = focusableModalElements[0];
const lastElement =
focusableModalElements[focusableModalElements.length - 1];

if (event.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
} else if (!event.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
};

Expand All @@ -103,15 +125,31 @@
}
};

const focusModalWrapper = () => {
if (modalRef.current) {
modalRef.current.tabIndex = -1;
modalRef.current.focus();
}
};

useEffect(() => {
if (focusRef?.current) {
focusRef.current.focus();
} else if (closeButtonRef.current) {
closeButtonRef.current.focus();
if (isElementVisible(closeButtonRef.current)) {
closeButtonRef.current.focus();
} else {
const firstFocusable = getVisibleFocusableElements()[0];
if (firstFocusable) {
firstFocusable.focus();
} else {
focusModalWrapper();
}
}
Comment thread
anusha-c18 marked this conversation as resolved.
} else {
modalRef.current.focus();
focusModalWrapper();
}
}, [focusRef]);

Check warning on line 152 in src/components/Modal/Modal.tsx

View workflow job for this annotation

GitHub Actions / Lint, build and test

React Hook useEffect has a missing dependency: 'getVisibleFocusableElements'. Either include it or remove the dependency array

useEffect(() => {
const keyListenersMap = new Map([
Expand All @@ -128,7 +166,7 @@
return () => {
document.removeEventListener("keydown", keyDown, true);
};
}, [close]);

Check warning on line 169 in src/components/Modal/Modal.tsx

View workflow job for this annotation

GitHub Actions / Lint, build and test

React Hook useEffect has missing dependencies: 'handleEscKey' and 'handleTabKey'. Either include them or remove the dependency array

const handleContentOnMouseDown = () => {
shouldClose.current = false;
Expand Down
Loading