Skip to content

Commit cd750b3

Browse files
chore(docs): Update ModalWithDropdown example (#12061)
* chore(docs): Update ModalWithDropdown example Example was using out-of-date PatternFly syntax from a prior version. It should now show how to properly add a dropdown to a modal that allows for keyboard accessibility. * Switch to using focus trap as location * Update test
1 parent 382777f commit cd750b3

File tree

9 files changed

+102
-16
lines changed

9 files changed

+102
-16
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: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import formStyles from '@patternfly/react-styles/css/components/Form/form';
1717

1818
### Basic modals
1919

20-
Basic modals give users the option to either confirm or cancel an action.
20+
Basic modals give users the option to either confirm or cancel an action.
2121

2222
To flag an open modal, use the `isOpen` property. To execute a callback when a modal is closed, use the `onClose` property.
2323

@@ -71,7 +71,7 @@ To choose a specific width for a modal, use the `width` property. The following
7171

7272
### Custom header
7373

74-
To add a custom header to a modal, your custom content must be passed as a child of the `<ModalHeader>` component. Do not pass the `title` property to `<ModalHeader>` when using a custom header.
74+
To add a custom header to a modal, your custom content must be passed as a child of the `<ModalHeader>` component. Do not pass the `title` property to `<ModalHeader>` when using a custom header.
7575

7676
```ts file="./ModalCustomHeader.tsx"
7777

@@ -113,9 +113,18 @@ To guide users through a series of steps in a modal, you can add a [wizard](/com
113113

114114
### With dropdown
115115

116-
To present a menu of actions or links to a user, you can add a [dropdown](/components/menus/dropdown) to a modal.
116+
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 `menuAppendTo` property to “parent”. 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

@@ -141,7 +150,7 @@ To enable form submission from a button in the modal's footer (outside of the `<
141150

142151
### Custom focus
143152

144-
To customize which element inside the modal receives focus when initially opened, use the `elementToFocus` property`.
153+
To customize which element inside the modal receives focus when initially opened, use the `elementToFocus` property`.
145154

146155
```ts file="./ModalCustomFocus.tsx"
147156

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ export const ModalWithDropdown: React.FunctionComponent = () => {
3232
};
3333

3434
const onFocus = () => {
35-
const element = document.getElementById('modal-dropdown-toggle');
36-
(element as HTMLElement).focus();
35+
if (typeof document !== 'undefined') {
36+
const element = document.getElementById('modal-dropdown-toggle');
37+
(element as HTMLElement)?.focus();
38+
}
3739
};
3840

3941
const onEscapePress = (event: KeyboardEvent) => {
@@ -45,6 +47,16 @@ export const ModalWithDropdown: React.FunctionComponent = () => {
4547
}
4648
};
4749

50+
const getAppendLocation = () => {
51+
// document doesn't exist when PatternFly website docs framework gets pre-rendered
52+
// this is just to avoid that issue - works fine at runtime without it
53+
if (typeof document !== 'undefined' && document.getElementById) {
54+
return document.getElementById('modal-with-dropdown-focus-trap');
55+
} else {
56+
return 'inline';
57+
}
58+
};
59+
4860
return (
4961
<Fragment>
5062
<Button variant="primary" onClick={handleModalToggle}>
@@ -57,14 +69,21 @@ export const ModalWithDropdown: React.FunctionComponent = () => {
5769
onEscapePress={onEscapePress}
5870
aria-labelledby="modal-with-dropdown"
5971
aria-describedby="modal-box-body-with-dropdown"
72+
focusTrapId="modal-with-dropdown-focus-trap"
6073
>
6174
<ModalHeader title="Dropdown modal" labelId="modal-with-dropdown" />
6275
<ModalBody id="modal-box-body-with-dropdown">
6376
<div>
64-
Set the dropdown <strong>menuAppendTo</strong> prop to <em>parent</em> in order to allow the dropdown menu
65-
break out of the modal container. You'll also want to handle closing of the modal yourself, by listening to
66-
the <strong>onEscapePress</strong> callback on the Modal component, so you can close the Dropdown first if
67-
it's 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.
6887
</div>
6988
<br />
7089
<div>
@@ -73,10 +92,18 @@ export const ModalWithDropdown: React.FunctionComponent = () => {
7392
onSelect={onSelect}
7493
onOpenChange={(isOpen: boolean) => setIsDropdownOpen(isOpen)}
7594
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
76-
<MenuToggle ref={toggleRef} onClick={handleDropdownToggle} isExpanded={isDropdownOpen}>
95+
<MenuToggle
96+
id="modal-dropdown-toggle"
97+
ref={toggleRef}
98+
onClick={handleDropdownToggle}
99+
isExpanded={isDropdownOpen}
100+
>
77101
Dropdown
78102
</MenuToggle>
79103
)}
104+
popperProps={{
105+
appendTo: getAppendLocation()
106+
}}
80107
>
81108
<DropdownList>
82109
<DropdownItem value={0} key="action">

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(
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { FocusTrap } from '../FocusTrap';
3+
4+
test('Focus trap can have an id added', () => {
5+
render(
6+
<FocusTrap
7+
children={<div>ReactNode</div>}
8+
className={'string'}
9+
active={false}
10+
paused={false}
11+
focusTrapOptions={undefined}
12+
id="focus-trap-id"
13+
data-testid="focus-trap"
14+
/>
15+
);
16+
expect(screen.getByTestId('focus-trap')).toHaveAttribute('id', 'focus-trap-id');
17+
});

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
/**
2-
* This test was generated
3-
*/
41
import { render } from '@testing-library/react';
52
import { FocusTrap } from '../../FocusTrap';
63

4+
/**
5+
* This test was generated
6+
*/
77
it('FocusTrap should match snapshot (auto-generated)', () => {
88
const { asFragment } = render(
99
<FocusTrap

0 commit comments

Comments
 (0)