diff --git a/packages/eui/changelogs/upcoming/8945.md b/packages/eui/changelogs/upcoming/8945.md new file mode 100644 index 00000000000..a22d95ef24a --- /dev/null +++ b/packages/eui/changelogs/upcoming/8945.md @@ -0,0 +1,2 @@ +- Added prop `focusTrapProps` on `EuiModal` + diff --git a/packages/eui/src/components/modal/modal.spec.tsx b/packages/eui/src/components/modal/modal.spec.tsx index ceb774724a5..8373af49d1f 100644 --- a/packages/eui/src/components/modal/modal.spec.tsx +++ b/packages/eui/src/components/modal/modal.spec.tsx @@ -10,7 +10,7 @@ /// /// -import React, { ReactNode, useState } from 'react'; +import React, { ReactNode, useRef, useState } from 'react'; import { EuiModal, EuiModalHeader, @@ -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(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 (
- Show confirm modal + + Show confirm modal + + {hasManualReturnFocus && ( + + Button label + + )} {isModalVisible && ( @@ -69,18 +101,29 @@ describe('EuiModal', () => { it('returns focus correctly when X close button is clicked', () => { cy.mount(); - 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(); - 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(); + 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', () => { diff --git a/packages/eui/src/components/modal/modal.stories.tsx b/packages/eui/src/components/modal/modal.stories.tsx index e1470db4440..892bfaabae0 100644 --- a/packages/eui/src/components/modal/modal.stories.tsx +++ b/packages/eui/src/components/modal/modal.stories.tsx @@ -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'; @@ -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 = { title: 'Layout/EuiModal/EuiModal', @@ -125,6 +126,67 @@ export const InitialFocus: Story = { }, }; +export const ManualReturnFocus: Story = { + parameters: { + codeSnippet: { + snippet: ` { + 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(null); + const [isOpen, setOpen] = useState(false); + + return ( + <> + + setOpen(true)}>Modal trigger + Custom trigger + + + {isOpen && ( + setOpen(false)} + focusTrapProps={{ + returnFocus: () => { + if (manualTriggerRef.current) { + manualTriggerRef.current.focus(); + return false; // Prevents the default return focus behavior + } + return true; // Fallback to default behavior + }, + }} + > + + Modal title + + + Modal body + + + Modal footer + + + )} + + ); + }, +}; + /* Story content components */ const StatefulModal = (props: EuiModalProps) => { diff --git a/packages/eui/src/components/modal/modal.tsx b/packages/eui/src/components/modal/modal.tsx index 36fbdbc5c52..06d61ade9cc 100644 --- a/packages/eui/src/components/modal/modal.tsx +++ b/packages/eui/src/components/modal/modal.tsx @@ -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'; @@ -49,6 +49,11 @@ export interface EuiModalProps extends HTMLAttributes { * 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; } export const EuiModal: FunctionComponent = ({ @@ -59,6 +64,7 @@ export const EuiModal: FunctionComponent = ({ maxWidth = true, role = 'dialog', style, + focusTrapProps, ...rest }) => { const onKeyDown = (event: React.KeyboardEvent) => { @@ -93,7 +99,12 @@ export const EuiModal: FunctionComponent = ({ return ( - +
{ }; ``` +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 + { + // 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'; diff --git a/packages/website/docs/components/containers/modal/index.mdx b/packages/website/docs/components/containers/modal/index.mdx index e3bc3359a92..8b64bcdc4ee 100644 --- a/packages/website/docs/components/containers/modal/index.mdx +++ b/packages/website/docs/components/containers/modal/index.mdx @@ -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 + { + // 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';