diff --git a/packages/react-core/src/components/Modal/Modal.tsx b/packages/react-core/src/components/Modal/Modal.tsx index 852c0a6dcd3..99a12d77b48 100644 --- a/packages/react-core/src/components/Modal/Modal.tsx +++ b/packages/react-core/src/components/Modal/Modal.tsx @@ -27,6 +27,8 @@ export interface ModalProps extends React.HTMLProps, OUIAProps { * focusable element will receive focus. */ elementToFocus?: HTMLElement | SVGElement | string; + /** Id of the focus trap in the ModalContent component */ + focusTrapId?: string; /** An id to use for the modal box container. */ id?: string; /** Flag to show the modal. */ diff --git a/packages/react-core/src/components/Modal/ModalContent.tsx b/packages/react-core/src/components/Modal/ModalContent.tsx index 935a7ccf230..38e311c8cfa 100644 --- a/packages/react-core/src/components/Modal/ModalContent.tsx +++ b/packages/react-core/src/components/Modal/ModalContent.tsx @@ -29,6 +29,8 @@ export interface ModalContentProps extends OUIAProps { * focusable element will receive focus. */ elementToFocus?: HTMLElement | SVGElement | string; + /** Id of the focus trap */ + focusTrapId?: string; /** Flag to show the modal. */ isOpen?: boolean; /** A callback for when the close button is clicked. */ @@ -69,6 +71,7 @@ export const ModalContent: React.FunctionComponent = ({ ouiaId, ouiaSafe = true, elementToFocus, + focusTrapId, ...props }: ModalContentProps) => { if (!isOpen) { @@ -122,6 +125,7 @@ export const ModalContent: React.FunctionComponent = ({ initialFocus: elementToFocus || undefined }} className={css(bullsEyeStyles.bullseye)} + id={focusTrapId} > {modalBox} diff --git a/packages/react-core/src/components/Modal/__tests__/Modal.test.tsx b/packages/react-core/src/components/Modal/__tests__/Modal.test.tsx index 4fda267e141..7c1c768008c 100644 --- a/packages/react-core/src/components/Modal/__tests__/Modal.test.tsx +++ b/packages/react-core/src/components/Modal/__tests__/Modal.test.tsx @@ -172,4 +172,13 @@ describe('Modal', () => { expect(asideSibling).not.toHaveAttribute('aria-hidden'); expect(articleSibling).not.toHaveAttribute('aria-hidden'); }); + + test('Modal can add id to focus trap correctly for use with dropdowns', () => { + render(); + expect(screen.getByRole('dialog', { name: /modal content/i }).parentElement).toHaveAttribute('id', 'focus-trap'); + expect(screen.getByRole('dialog', { name: /modal content/i }).parentElement).toHaveAttribute( + 'class', + 'pf-v6-l-bullseye' + ); + }); }); diff --git a/packages/react-core/src/components/Modal/__tests__/ModalContent.test.tsx b/packages/react-core/src/components/Modal/__tests__/ModalContent.test.tsx index 8f2d272982a..c75a3eadecb 100644 --- a/packages/react-core/src/components/Modal/__tests__/ModalContent.test.tsx +++ b/packages/react-core/src/components/Modal/__tests__/ModalContent.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { ModalContent } from '../ModalContent'; @@ -43,3 +43,19 @@ test('Modal Content Test with onclose', () => { ); expect(asFragment()).toMatchSnapshot(); }); + +test('Modal content can add id to focus trap correctly for use with dropdowns', () => { + render( + + This is a ModalBox header + + ); + expect(screen.getByRole('dialog', { name: /This is a ModalBox header/i }).parentElement).toHaveAttribute( + 'id', + 'focus-trap' + ); + expect(screen.getByRole('dialog', { name: /This is a ModalBox header/i }).parentElement).toHaveAttribute( + 'class', + 'pf-v6-l-bullseye' + ); +}); diff --git a/packages/react-core/src/components/Modal/examples/Modal.md b/packages/react-core/src/components/Modal/examples/Modal.md index 9577b2ede69..db5c1557c64 100644 --- a/packages/react-core/src/components/Modal/examples/Modal.md +++ b/packages/react-core/src/components/Modal/examples/Modal.md @@ -17,7 +17,7 @@ import formStyles from '@patternfly/react-styles/css/components/Form/form'; ### Basic modals -Basic modals give users the option to either confirm or cancel an action. +Basic modals give users the option to either confirm or cancel an action. To flag an open modal, use the `isOpen` property. To execute a callback when a modal is closed, use the `onClose` property. @@ -71,7 +71,7 @@ To choose a specific width for a modal, use the `width` property. The following ### Custom header -To add a custom header to a modal, your custom content must be passed as a child of the `` component. Do not pass the `title` property to `` when using a custom header. +To add a custom header to a modal, your custom content must be passed as a child of the `` component. Do not pass the `title` property to `` when using a custom header. ```ts file="./ModalCustomHeader.tsx" @@ -113,9 +113,18 @@ To guide users through a series of steps in a modal, you can add a [wizard](/com ### With dropdown -To present a menu of actions or links to a user, you can add a [dropdown](/components/menus/dropdown) to a modal. +To present a menu of actions or links to a user, you can add a [dropdown](/components/menus/dropdown) to a modal. -To allow the dropdown to visually break out of the modal container, set the `menuAppendTo` property to “parent”. Handle the modal’s closing behavior by listening to the `onEscapePress` callback on the `` component. This allows the "escape" key to collapse the dropdown without closing the entire modal. +Using the Dropdown's default append location will interfere with keyboard accessibility due to the modal's +built-in focus trap. To allow the dropdown to visually break out of the modal container, set the Dropdown's +`popperProps appendTo` property to id of the focus trap for the modal. This can be done by adding prop +`focusTrapId` to the modal, and then setting the append location to that as per this example. Otherwise you +can use `inline` to allow it to scroll within the modal itself. Appending to the focus trap should allow the +menu to expand visually outside the Modal (no scrollbar created in the Modal itself), and still allow +focusing the content within that menu via keyboard. You should also handle the modal's closing behavior by +listening to the +`onEscapePress` callback on the `` component. This allows the "escape" key to collapse the +dropdown without closing the entire modal. ```ts file="./ModalWithDropdown.tsx" @@ -141,7 +150,7 @@ To enable form submission from a button in the modal's footer (outside of the `< ### Custom focus -To customize which element inside the modal receives focus when initially opened, use the `elementToFocus` property`. +To customize which element inside the modal receives focus when initially opened, use the `elementToFocus` property`. ```ts file="./ModalCustomFocus.tsx" diff --git a/packages/react-core/src/components/Modal/examples/ModalWithDropdown.tsx b/packages/react-core/src/components/Modal/examples/ModalWithDropdown.tsx index 1e2104dbb69..dde57f3cc06 100644 --- a/packages/react-core/src/components/Modal/examples/ModalWithDropdown.tsx +++ b/packages/react-core/src/components/Modal/examples/ModalWithDropdown.tsx @@ -32,8 +32,10 @@ export const ModalWithDropdown: React.FunctionComponent = () => { }; const onFocus = () => { - const element = document.getElementById('modal-dropdown-toggle'); - (element as HTMLElement).focus(); + if (typeof document !== 'undefined') { + const element = document.getElementById('modal-dropdown-toggle'); + (element as HTMLElement)?.focus(); + } }; const onEscapePress = (event: KeyboardEvent) => { @@ -45,6 +47,16 @@ export const ModalWithDropdown: React.FunctionComponent = () => { } }; + const getAppendLocation = () => { + // document doesn't exist when PatternFly website docs framework gets pre-rendered + // this is just to avoid that issue - works fine at runtime without it + if (typeof document !== 'undefined' && document.getElementById) { + return document.getElementById('modal-with-dropdown-focus-trap'); + } else { + return 'inline'; + } + }; + return (