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
2 changes: 2 additions & 0 deletions packages/react-core/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface ModalProps extends React.HTMLProps<HTMLDivElement>, 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. */
Expand Down
4 changes: 4 additions & 0 deletions packages/react-core/src/components/Modal/ModalContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -69,6 +71,7 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
ouiaId,
ouiaSafe = true,
elementToFocus,
focusTrapId,
...props
}: ModalContentProps) => {
if (!isOpen) {
Expand Down Expand Up @@ -122,6 +125,7 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
initialFocus: elementToFocus || undefined
}}
className={css(bullsEyeStyles.bullseye)}
id={focusTrapId}
>
{modalBox}
</FocusTrap>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Modal focusTrapId="focus-trap" isOpen onClose={jest.fn()} children="modal content" />);
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'
);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';

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

Expand Down Expand Up @@ -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(
<ModalContent focusTrapId="focus-trap" isOpen {...modalContentProps}>
This is a ModalBox header
</ModalContent>
);
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'
);
});
19 changes: 14 additions & 5 deletions packages/react-core/src/components/Modal/examples/Modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 `<ModalHeader>` component. Do not pass the `title` property to `<ModalHeader>` when using a custom header.
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.

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

Expand Down Expand Up @@ -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 `<Modal>` 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 `<Modal>` component. This allows the "escape" key to collapse the
dropdown without closing the entire modal.

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

Expand All @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 (
<Fragment>
<Button variant="primary" onClick={handleModalToggle}>
Expand All @@ -57,14 +69,21 @@ export const ModalWithDropdown: React.FunctionComponent = () => {
onEscapePress={onEscapePress}
aria-labelledby="modal-with-dropdown"
aria-describedby="modal-box-body-with-dropdown"
focusTrapId="modal-with-dropdown-focus-trap"
>
<ModalHeader title="Dropdown modal" labelId="modal-with-dropdown" />
<ModalBody id="modal-box-body-with-dropdown">
<div>
Set the dropdown <strong>menuAppendTo</strong> prop to <em>parent</em> in order to allow the dropdown menu
break out of the modal container. You'll also want to handle closing of the modal yourself, by listening to
the <strong>onEscapePress</strong> callback on the Modal component, so you can close the Dropdown first if
it's open 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
<em>onEscapePress</em> callback on the Modal component. This allows the "escape" key to collapse the
dropdown without closing the entire modal.
</div>
<br />
<div>
Expand All @@ -73,10 +92,18 @@ export const ModalWithDropdown: React.FunctionComponent = () => {
onSelect={onSelect}
onOpenChange={(isOpen: boolean) => setIsDropdownOpen(isOpen)}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle ref={toggleRef} onClick={handleDropdownToggle} isExpanded={isDropdownOpen}>
<MenuToggle
id="modal-dropdown-toggle"
ref={toggleRef}
onClick={handleDropdownToggle}
isExpanded={isDropdownOpen}
>
Dropdown
</MenuToggle>
)}
popperProps={{
appendTo: getAppendLocation()
}}
>
<DropdownList>
<DropdownItem value={0} key="action">
Expand Down
2 changes: 2 additions & 0 deletions packages/react-core/src/helpers/FocusTrap/FocusTrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface FocusTrapProps extends ComponentPropsWithRef<'div'> {
focusTrapOptions?: FocusTrapOptions;
/** Prevent from scrolling to the previously focused element on deactivation */
preventScrollOnDeactivate?: boolean;
/** Unique id that can optionally be applied to focus trap */
id?: string;
}

export const FocusTrap = forwardRef<HTMLDivElement, FocusTrapProps>(function FocusTrap(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react';
import { FocusTrap } from '../FocusTrap';

test('Focus trap can have an id added', () => {
render(
<FocusTrap
children={<div>ReactNode</div>}
className={'string'}
active={false}
paused={false}
focusTrapOptions={undefined}
id="focus-trap-id"
data-testid="focus-trap"
/>
);
expect(screen.getByTestId('focus-trap')).toHaveAttribute('id', 'focus-trap-id');
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* This test was generated
*/
import { render } from '@testing-library/react';
import { FocusTrap } from '../../FocusTrap';

/**
* This test was generated
*/
it('FocusTrap should match snapshot (auto-generated)', () => {
const { asFragment } = render(
<FocusTrap
Expand Down
Loading