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
2 changes: 2 additions & 0 deletions packages/eui/changelogs/upcoming/8945.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Added prop `focusTrapProps` on `EuiModal`

53 changes: 48 additions & 5 deletions packages/eui/src/components/modal/modal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/// <reference types="cypress-real-events" />
/// <reference types="../../../cypress/support" />

import React, { ReactNode, useState } from 'react';
import React, { ReactNode, useRef, useState } from 'react';
import {
EuiModal,
EuiModalHeader,
Expand All @@ -22,20 +22,52 @@ import {
import { EuiButton } from '../button';
import { EuiPopover } from '../popover';

const Modal = ({ content }: { content?: ReactNode }) => {
const Modal = ({
content,
hasManualReturnFocus,
}: {
content?: ReactNode;
hasManualReturnFocus?: boolean;
}) => {
const manualTriggerRef = useRef<HTMLButtonElement>(null);
const [isModalVisible, setIsModalVisible] = useState(false);
const closeModal = () => setIsModalVisible(false);
const showModal = () => setIsModalVisible(true);

const focusTrapProps = hasManualReturnFocus
? {
returnFocus: () => {
if (manualTriggerRef.current) {
manualTriggerRef.current.focus();

return false;
}

return true;
},
}
: {};

const modalProps: EuiModalProps = {
title: 'Do this thing',
onClose: closeModal,
children: null,
focusTrapProps,
};

return (
<div>
<EuiButton onClick={showModal}>Show confirm modal</EuiButton>
<EuiButton data-test-subj="modal-trigger" onClick={showModal}>
Show confirm modal
</EuiButton>
{hasManualReturnFocus && (
<EuiButton
data-test-subj="modal-manual-trigger"
buttonRef={manualTriggerRef}
>
Button label
</EuiButton>
)}
{isModalVisible && (
<EuiModal {...modalProps}>
<EuiModalHeader>
Expand Down Expand Up @@ -69,18 +101,29 @@ describe('EuiModal', () => {

it('returns focus correctly when X close button is clicked', () => {
cy.mount(<Modal />);
cy.get('button.euiButton').click();
cy.get('[data-test-subj="modal-trigger"]').click();
cy.get('div.euiModal').should('exist');
cy.get('button.euiButtonIcon').click();
cy.get('div.euiModal').should('not.exist');
cy.get('[data-test-subj="modal-trigger"]').should('have.focus');
});

it('handles focus correctly when Close button is clicked', () => {
cy.mount(<Modal />);
cy.get('button.euiButton').click();
cy.get('[data-test-subj="modal-trigger"]').click();
cy.get('div.euiModal').should('exist');
cy.get('div.euiModalFooter > button').click();
cy.get('div.euiModal').should('not.exist');
cy.get('[data-test-subj="modal-trigger"]').should('have.focus');
});

it('correctly returns handles manual focus return', () => {
cy.mount(<Modal hasManualReturnFocus />);
cy.get('[data-test-subj="modal-trigger"]').click();
cy.get('div.euiModal').should('exist');
cy.get('button.euiButtonIcon').click();
cy.get('div.euiModal').should('not.exist');
cy.get('[data-test-subj="modal-manual-trigger"]').should('have.focus');
});

describe('key navigation', () => {
Expand Down
64 changes: 63 additions & 1 deletion packages/eui/src/components/modal/modal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Side Public License, v 1.
*/

import React, { MouseEvent, useState } from 'react';
import React, { MouseEvent, useRef, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';

Expand All @@ -19,6 +19,7 @@ import { EuiModalBody } from './modal_body';
import { EuiModalFooter } from './modal_footer';
import { EuiModal, EuiModalProps } from './modal';
import { LOKI_SELECTORS } from '../../../.storybook/loki';
import { EuiFlexGroup } from '../flex';

const meta: Meta<EuiModalProps> = {
title: 'Layout/EuiModal/EuiModal',
Expand Down Expand Up @@ -125,6 +126,67 @@ export const InitialFocus: Story = {
},
};

export const ManualReturnFocus: Story = {
parameters: {
codeSnippet: {
snippet: `<EuiModal {{...STORY_ARGS}} focusTrapProps={{
returnFocus: () => {
if (manualTriggerRef.current) {
manualTriggerRef.current.focus();
return false;
}

return true;
}
}} />
`,
},
loki: {
skip: true, // used for functional testing only
},
},
render: function Render(args) {
const manualTriggerRef = useRef<HTMLButtonElement>(null);
const [isOpen, setOpen] = useState(false);

return (
<>
<EuiFlexGroup>
<EuiButton onClick={() => setOpen(true)}>Modal trigger</EuiButton>
<EuiButton buttonRef={manualTriggerRef}>Custom trigger</EuiButton>
</EuiFlexGroup>

{isOpen && (
<EuiModal
aria-labelledby="modalTitleId"
{...args}
onClose={() => setOpen(false)}
focusTrapProps={{
returnFocus: () => {
if (manualTriggerRef.current) {
manualTriggerRef.current.focus();
return false; // Prevents the default return focus behavior
}
return true; // Fallback to default behavior
},
}}
>
<EuiModalHeader>
<EuiModalHeaderTitle>Modal title</EuiModalHeaderTitle>
</EuiModalHeader>

<EuiModalBody>Modal body</EuiModalBody>

<EuiModalFooter>
<EuiButton fill>Modal footer</EuiButton>
</EuiModalFooter>
</EuiModal>
)}
</>
);
},
};

/* Story content components */

const StatefulModal = (props: EuiModalProps) => {
Expand Down
15 changes: 13 additions & 2 deletions packages/eui/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { isDOMNode } from '../../utils';

import { EuiButtonIcon } from '../button';

import { EuiFocusTrap } from '../focus_trap';
import { EuiFocusTrap, EuiFocusTrapProps } from '../focus_trap';
import { EuiOverlayMask } from '../overlay_mask';
import { EuiI18n } from '../i18n';

Expand Down Expand Up @@ -49,6 +49,11 @@ export interface EuiModalProps extends HTMLAttributes<HTMLDivElement> {
* or need a user's attention should use "alertdialog".
*/
role?: 'dialog' | 'alertdialog';
/**
* Object of props passed to EuiFocusTrap.
* `returnFocus` defines the return focus behavior and provides the possibility to check the available target element or opt out of the behavior in favor of manually returning focus
*/
focusTrapProps?: Pick<EuiFocusTrapProps, 'returnFocus'>;
}

export const EuiModal: FunctionComponent<EuiModalProps> = ({
Expand All @@ -59,6 +64,7 @@ export const EuiModal: FunctionComponent<EuiModalProps> = ({
maxWidth = true,
role = 'dialog',
style,
focusTrapProps,
...rest
}) => {
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -93,7 +99,12 @@ export const EuiModal: FunctionComponent<EuiModalProps> = ({

return (
<EuiOverlayMask>
<EuiFocusTrap initialFocus={initialFocus} scrollLock preventScrollOnFocus>
<EuiFocusTrap
{...focusTrapProps}
initialFocus={initialFocus}
scrollLock
preventScrollOnFocus
>
<div
css={cssStyles}
className={classes}
Expand Down
38 changes: 38 additions & 0 deletions packages/website/docs/components/containers/flyout/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,44 @@ export default () => {
};
```

import { Example } from '@site/src/components';

### Focus management

`EuiFlyout` used with `type="overlay"` manages focus for keyboard navigation and accessibility. This ensures users can navigate modal content effectively,
especially those using screen readers or keyboard-only navigation.

When a `EuiFlyout` is used, it will automatically:

1. Move focus to the flyout when opened
2. Trap focus inside the flyout while open
3. Return focus to the trigger element when closed

#### Manual return focus control

Use `focusTrapProps` to customize the focus behavior:

```tsx
<EuiFlyout
focusTrapProps={{
returnFocus: (triggerElement) => {
// Return true to use default behavior (focus triggerElement)
// Return false to prevent default behavior
if (customElement.current) {
customElement.current.focus();
return false;
}
return true;
},
}}
/>
```

:::warning Manually return focus when the trigger element is removed
If the trigger element is removed from the DOM before the flyout closes, focus will be "lost" to the document body.
In this case, manually return focus to an appropriate element using `focusTrapProps.returnFocus`.
:::

### Push flyout

import PushFlyout from './_flyout_push.mdx';
Expand Down
38 changes: 38 additions & 0 deletions packages/website/docs/components/containers/modal/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,44 @@ export default () => {

```

import { Example } from '@site/src/components';

### Focus management

`EuiModal` manages focus for keyboard navigation and accessibility. This ensures users can navigate modal content effectively,
especially those using screen readers or keyboard-only navigation.

When a `EuiModal` is used, it will automatically:

1. Move focus to the modal when opened
2. Trap focus inside the modal while open
3. Return focus to the trigger element when closed

#### Manual return focus control

Use `focusTrapProps` to customize the focus behavior:

```tsx
<EuiModal
focusTrapProps={{
returnFocus: (triggerElement) => {
// Return true to use default behavior (focus triggerElement)
// Return false to prevent default behavior
if (customElement.current) {
customElement.current.focus();
return false;
}
return true;
},
}}
/>
```

:::warning Manually return focus when the trigger element is removed
If the trigger element is removed from the DOM before the modal closes, focus will be "lost" to the document body.
In this case, manually return focus to an appropriate element using `focusTrapProps.returnFocus`.
:::

### Confirm modal

import ConfirmModal from './_confirm_modal.mdx';
Expand Down