Skip to content

Commit bca47fb

Browse files
committed
Switch to using focus trap as location
1 parent 73868fb commit bca47fb

File tree

8 files changed

+77
-13
lines changed

8 files changed

+77
-13
lines changed

packages/react-core/src/components/Modal/Modal.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface ModalProps extends React.HTMLProps<HTMLDivElement>, OUIAProps {
2727
* focusable element will receive focus.
2828
*/
2929
elementToFocus?: HTMLElement | SVGElement | string;
30+
/** Id of the focus trap in the ModalContent component */
31+
focusTrapId?: string;
3032
/** An id to use for the modal box container. */
3133
id?: string;
3234
/** Flag to show the modal. */

packages/react-core/src/components/Modal/ModalContent.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export interface ModalContentProps extends OUIAProps {
2929
* focusable element will receive focus.
3030
*/
3131
elementToFocus?: HTMLElement | SVGElement | string;
32+
/** Id of the focus trap */
33+
focusTrapId?: string;
3234
/** Flag to show the modal. */
3335
isOpen?: boolean;
3436
/** A callback for when the close button is clicked. */
@@ -69,6 +71,7 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
6971
ouiaId,
7072
ouiaSafe = true,
7173
elementToFocus,
74+
focusTrapId,
7275
...props
7376
}: ModalContentProps) => {
7477
if (!isOpen) {
@@ -122,6 +125,7 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
122125
initialFocus: elementToFocus || undefined
123126
}}
124127
className={css(bullsEyeStyles.bullseye)}
128+
id={focusTrapId}
125129
>
126130
{modalBox}
127131
</FocusTrap>

packages/react-core/src/components/Modal/__tests__/Modal.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,13 @@ describe('Modal', () => {
172172
expect(asideSibling).not.toHaveAttribute('aria-hidden');
173173
expect(articleSibling).not.toHaveAttribute('aria-hidden');
174174
});
175+
176+
test('Modal can add id to focus trap correctly for use with dropdowns', () => {
177+
render(<Modal focusTrapId="focus-trap" isOpen onClose={jest.fn()} children="modal content" />);
178+
expect(screen.getByRole('dialog', { name: /modal content/i }).parentElement).toHaveAttribute('id', 'focus-trap');
179+
expect(screen.getByRole('dialog', { name: /modal content/i }).parentElement).toHaveAttribute(
180+
'class',
181+
'pf-v6-l-bullseye'
182+
);
183+
});
175184
});

packages/react-core/src/components/Modal/__tests__/ModalContent.test.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render } from '@testing-library/react';
1+
import { render, screen } from '@testing-library/react';
22

33
import { ModalContent } from '../ModalContent';
44

@@ -43,3 +43,19 @@ test('Modal Content Test with onclose', () => {
4343
);
4444
expect(asFragment()).toMatchSnapshot();
4545
});
46+
47+
test('Modal content can add id to focus trap correctly for use with dropdowns', () => {
48+
render(
49+
<ModalContent focusTrapId="focus-trap" isOpen {...modalContentProps}>
50+
This is a ModalBox header
51+
</ModalContent>
52+
);
53+
expect(screen.getByRole('dialog', { name: /This is a ModalBox header/i }).parentElement).toHaveAttribute(
54+
'id',
55+
'focus-trap'
56+
);
57+
expect(screen.getByRole('dialog', { name: /This is a ModalBox header/i }).parentElement).toHaveAttribute(
58+
'class',
59+
'pf-v6-l-bullseye'
60+
);
61+
});

packages/react-core/src/components/Modal/examples/Modal.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,16 @@ To guide users through a series of steps in a modal, you can add a [wizard](/com
115115

116116
To present a menu of actions or links to a user, you can add a [dropdown](/components/menus/dropdown) to a modal.
117117

118-
To allow the dropdown to visually break out of the modal container, set the `popperProps appendTo` property to one of the parent content items in the modal. Otherwise you can use `inline` to allow it to scroll within the modal itself. Using the Dropdown's default append location will interfere with keyboard accessibility due to the modal's built-in focus trap. Handle the modal’s closing behavior by listening to the `onEscapePress` callback on the `<Modal>` component. This allows the "escape" key to collapse the dropdown without closing the entire modal.
118+
Using the Dropdown's default append location will interfere with keyboard accessibility due to the modal's
119+
built-in focus trap. To allow the dropdown to visually break out of the modal container, set the Dropdown's
120+
`popperProps appendTo` property to id of the focus trap for the modal. This can be done by adding prop
121+
`focusTrapId` to the modal, and then setting the append location to that as per this example. Otherwise you
122+
can use `inline` to allow it to scroll within the modal itself. Appending to the focus trap should allow the
123+
menu to expand visually outside the Modal (no scrollbar created in the Modal itself), and still allow
124+
focusing the content within that menu via keyboard. You should also handle the modal's closing behavior by
125+
listening to the
126+
`onEscapePress` callback on the `<Modal>` component. This allows the "escape" key to collapse the
127+
dropdown without closing the entire modal.
119128

120129
```ts file="./ModalWithDropdown.tsx"
121130

packages/react-core/src/components/Modal/examples/ModalWithDropdown.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export const ModalWithDropdown: React.FunctionComponent = () => {
5151
// document doesn't exist when PatternFly website docs framework gets pre-rendered
5252
// this is just to avoid that issue - works fine at runtime without it
5353
if (typeof document !== 'undefined' && document.getElementById) {
54-
return document.getElementById('modal-box-body-with-dropdown');
54+
return document.getElementById('modal-with-dropdown-focus-trap');
5555
} else {
5656
return 'inline';
5757
}
@@ -69,17 +69,21 @@ export const ModalWithDropdown: React.FunctionComponent = () => {
6969
onEscapePress={onEscapePress}
7070
aria-labelledby="modal-with-dropdown"
7171
aria-describedby="modal-box-body-with-dropdown"
72+
focusTrapId="modal-with-dropdown-focus-trap"
7273
>
7374
<ModalHeader title="Dropdown modal" labelId="modal-with-dropdown" />
7475
<ModalBody id="modal-box-body-with-dropdown">
7576
<div>
76-
Set the Dropdown's popperProps appendTo prop to <em>inline</em> if you want the dropdown to scroll within
77-
the modal. Set the popperProps appendTo prop to the id of the <em>parent modal or modal body</em> in order
78-
to allow the dropdown menu to break out of the modal container. Using the default dropdown location will
79-
break keyboard control over the dropdown due to the modal's built-in focus trap. You'll also want to handle
80-
closing of the modal yourself, by listening to the
81-
<strong>onEscapePress</strong> callback on the Modal component, so you can close the Dropdown first if it's
82-
open without closing the entire modal.
77+
Using the Dropdown's default append location will interfere with keyboard accessibility due to the modal's
78+
built-in focus trap. To allow the dropdown to visually break out of the modal container, set the Dropdown's
79+
popperProps appendTo property to id of the focus trap for the modal. This can be done by adding prop
80+
focusTrapId to the modal, and then setting the append location to that as per this example. Otherwise you
81+
can use "inline" to allow it to scroll within the modal itself. Appending to the focus trap should allow the
82+
menu to expand visually outside the Modal (no scrollbar created in the Modal itself), and still allow
83+
focusing the content within that menu via keyboard. You should also handle the modal's closing behavior by
84+
listening to the
85+
<em>onEscapePress</em> callback on the Modal component. This allows the "escape" key to collapse the
86+
dropdown without closing the entire modal.
8387
</div>
8488
<br />
8589
<div>

packages/react-core/src/helpers/FocusTrap/FocusTrap.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface FocusTrapProps extends ComponentPropsWithRef<'div'> {
88
focusTrapOptions?: FocusTrapOptions;
99
/** Prevent from scrolling to the previously focused element on deactivation */
1010
preventScrollOnDeactivate?: boolean;
11+
/** Unique id that can optionally be applied to focus trap */
12+
id?: string;
1113
}
1214

1315
export const FocusTrap = forwardRef<HTMLDivElement, FocusTrapProps>(function FocusTrap(

packages/react-core/src/helpers/FocusTrap/__tests__/Generated/FocusTrap.test.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { FocusTrap } from '../../FocusTrap';
3+
14
/**
25
* This test was generated
36
*/
4-
import { render } from '@testing-library/react';
5-
import { FocusTrap } from '../../FocusTrap';
6-
77
it('FocusTrap should match snapshot (auto-generated)', () => {
88
const { asFragment } = render(
99
<FocusTrap
@@ -16,3 +16,21 @@ it('FocusTrap should match snapshot (auto-generated)', () => {
1616
);
1717
expect(asFragment()).toMatchSnapshot();
1818
});
19+
20+
/**
21+
* This test was not generated
22+
*/
23+
test('Focus trap can have an id added', () => {
24+
render(
25+
<FocusTrap
26+
children={<div>ReactNode</div>}
27+
className={'string'}
28+
active={false}
29+
paused={false}
30+
focusTrapOptions={undefined}
31+
id="focus-trap-id"
32+
data-testid="focus-trap"
33+
/>
34+
);
35+
expect(screen.getByTestId('focus-trap')).toHaveAttribute('id', 'focus-trap-id');
36+
});

0 commit comments

Comments
 (0)