diff --git a/packages/block-editor/src/hooks/block-rename-ui.js b/packages/block-editor/src/hooks/block-rename-ui.js index 835a09556aed5e..ddb4b743df3e51 100644 --- a/packages/block-editor/src/hooks/block-rename-ui.js +++ b/packages/block-editor/src/hooks/block-rename-ui.js @@ -69,6 +69,7 @@ function RenameModal( { blockName, originalBlockName, onClose, onSave } ) { aria={ { describedby: dialogDescription, } } + focusOnMount="firstContentElement" >

{ __( 'Enter a custom name for this block.' ) } diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index ef00a1afd6e2d7..9fc7a1f98704fd 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -4,6 +4,7 @@ ### Enhancements +- Add new option `firstContentElement` to Modal's `focusOnMount` prop to allow consumers to focus the first element within the Modal's **contents** ([#54590](https://github.com/WordPress/gutenberg/pull/54590)). - `Notice`: Improve accessibility by adding visually hidden text to clarify what a notice text is about and the notice type (success, error, warning, info) ([#54498](https://github.com/WordPress/gutenberg/pull/54498)). - Making Circular Option Picker a `listbox`. Note that while this changes some public API, new props are optional, and currently have default values; this will change in another patch ([#52255](https://github.com/WordPress/gutenberg/pull/52255)). - `ToggleGroupControl`: Rewrite backdrop animation using framer motion shared layout animations, add better support for controlled and uncontrolled modes ([#50278](https://github.com/WordPress/gutenberg/pull/50278)). diff --git a/packages/components/src/modal/README.md b/packages/components/src/modal/README.md index 01cad7d6ff2e0f..31cfdfa59c8c70 100644 --- a/packages/components/src/modal/README.md +++ b/packages/components/src/modal/README.md @@ -187,10 +187,16 @@ Titles are required for accessibility reasons, see `aria.labelledby` and `title` - Required: No -#### `focusOnMount`: `boolean | 'firstElement'` +#### `focusOnMount`: `boolean | 'firstElement'` | 'firstContentElement' If this property is true, it will focus the first tabbable element rendered in the modal. +If this property is false, focus will not be transferred and it is the responsibility of the consumer to ensure accessible focus management. + +If set to `firstElement` focus will be placed on the first tabbable element anywhere within the Modal. + +If set to `firstContentElement` focus will be placed on the first tabbable element within the Modal's **content** (i.e. children). Note that it is the responsibility of the consumer to ensure there is at least one tabbable element within the children **or the focus will be lost**. + - Required: No - Default: `true` diff --git a/packages/components/src/modal/index.tsx b/packages/components/src/modal/index.tsx index d21a3f9ae3535e..139b0805a04dbb 100644 --- a/packages/components/src/modal/index.tsx +++ b/packages/components/src/modal/index.tsx @@ -71,11 +71,23 @@ function UnforwardedModal( } = props; const ref = useRef< HTMLDivElement >(); + const instanceId = useInstanceId( Modal ); const headingId = title ? `components-modal-header-${ instanceId }` : aria.labelledby; - const focusOnMountRef = useFocusOnMount( focusOnMount ); + + // The focus hook does not support 'firstContentElement' but this is a valid + // value for the Modal's focusOnMount prop. The following code ensures the focus + // hook will focus the first focusable node within the element to which it is applied. + // When `firstContentElement` is passed as the value of the focusOnMount prop, + // the focus hook is applied to the Modal's content element. + // Otherwise, the focus hook is applied to the Modal's ref. This ensures that the + // focus hook will focus the first element in the Modal's **content** when + // `firstContentElement` is passed. + const focusOnMountRef = useFocusOnMount( + focusOnMount === 'firstContentElement' ? 'firstElement' : focusOnMount + ); const constrainedTabbingRef = useConstrainedTabbing(); const focusReturnRef = useFocusReturn(); const focusOutsideProps = useFocusOutside( onRequestClose ); @@ -223,7 +235,9 @@ function UnforwardedModal( ref={ useMergeRefs( [ constrainedTabbingRef, focusReturnRef, - focusOnMountRef, + focusOnMount !== 'firstContentElement' + ? focusOnMountRef + : null, ] ) } role={ role } aria-label={ contentLabel } @@ -283,7 +297,17 @@ function UnforwardedModal( ) } ) } -

{ children }
+ +
+ { children } +
diff --git a/packages/components/src/modal/stories/index.story.tsx b/packages/components/src/modal/stories/index.story.tsx index 8405a6eb0113e3..bfe5f4560e0d88 100644 --- a/packages/components/src/modal/stories/index.story.tsx +++ b/packages/components/src/modal/stories/index.story.tsx @@ -28,7 +28,8 @@ const meta: Meta< typeof Modal > = { control: { type: null }, }, focusOnMount: { - control: { type: 'boolean' }, + options: [ true, false, 'firstElement', 'firstContentElement' ], + control: { type: 'select' }, }, role: { control: { type: 'text' }, diff --git a/packages/components/src/modal/test/index.tsx b/packages/components/src/modal/test/index.tsx index d3bd6aea888132..ae606bb8315136 100644 --- a/packages/components/src/modal/test/index.tsx +++ b/packages/components/src/modal/test/index.tsx @@ -13,6 +13,7 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import Modal from '../'; +import type { ModalProps } from '../types'; const noop = () => {}; @@ -236,4 +237,127 @@ describe( 'Modal', () => { screen.getByText( 'A sweet button', { selector: 'button' } ) ).toBeInTheDocument(); } ); + + describe( 'Focus handling', () => { + let originalGetClientRects: () => DOMRectList; + + const FocusMountDemo = ( { + focusOnMount, + }: Pick< ModalProps, 'focusOnMount' > ) => { + const [ isShown, setIsShown ] = useState( false ); + return ( + <> + + { isShown && ( + setIsShown( false ) } + > +

Modal content

+ + First Focusable Content Element + + + + Another Focusable Content Element + +
+ ) } + + ); + }; + + beforeEach( () => { + /** + * The test environment does not have a layout engine, so we need to mock + * the getClientRects method. This ensures that the focusable elements can be + * found by the `focusOnMount` logic which depends on layout information + * to determine if the element is visible or not. + * See https://github.com/WordPress/gutenberg/blob/trunk/packages/dom/src/focusable.js#L55-L61. + */ + // @ts-expect-error We're not trying to comply to the DOM spec, only mocking + window.HTMLElement.prototype.getClientRects = function () { + return [ 'trick-jsdom-into-having-size-for-element-rect' ]; + }; + } ); + + afterEach( () => { + // Restore original HTMLElement prototype. + // See beforeEach for details. + window.HTMLElement.prototype.getClientRects = + originalGetClientRects; + } ); + + it( 'should focus the Modal dialog by default when `focusOnMount` prop is not provided', async () => { + const user = userEvent.setup(); + + render( ); + + const opener = screen.getByRole( 'button', { + name: 'Toggle Modal', + } ); + + await user.click( opener ); + + expect( screen.getByRole( 'dialog' ) ).toHaveFocus(); + } ); + + it( 'should focus the Modal dialog when `true` passed as value for `focusOnMount` prop', async () => { + const user = userEvent.setup(); + + render( ); + + const opener = screen.getByRole( 'button', { + name: 'Toggle Modal', + } ); + + await user.click( opener ); + + expect( screen.getByRole( 'dialog' ) ).toHaveFocus(); + } ); + + it( 'should focus the first focusable element in the contents (if found) when `firstContentElement` passed as value for `focusOnMount` prop', async () => { + const user = userEvent.setup(); + + render( ); + + const opener = screen.getByRole( 'button' ); + + await user.click( opener ); + + expect( + screen.getByText( 'First Focusable Content Element' ) + ).toHaveFocus(); + } ); + + it( 'should focus the first element anywhere within the Modal when `firstElement` passed as value for `focusOnMount` prop', async () => { + const user = userEvent.setup(); + + render( ); + + const opener = screen.getByRole( 'button' ); + + await user.click( opener ); + + expect( + screen.getByRole( 'button', { name: 'Close' } ) + ).toHaveFocus(); + } ); + + it( 'should not move focus when `false` passed as value for `focusOnMount` prop', async () => { + const user = userEvent.setup(); + + render( ); + + const opener = screen.getByRole( 'button', { + name: 'Toggle Modal', + } ); + + await user.click( opener ); + + expect( opener ).toHaveFocus(); + } ); + } ); } ); diff --git a/packages/components/src/modal/types.ts b/packages/components/src/modal/types.ts index 2106a6087e9943..a001686901d4b6 100644 --- a/packages/components/src/modal/types.ts +++ b/packages/components/src/modal/types.ts @@ -68,7 +68,9 @@ export type ModalProps = { * * @default true */ - focusOnMount?: Parameters< typeof useFocusOnMount >[ 0 ]; + focusOnMount?: + | Parameters< typeof useFocusOnMount >[ 0 ] + | 'firstContentElement'; /** * Elements that are injected into the modal header to the left of the close button (if rendered). * Hidden if `__experimentalHideHeader` is `true`. diff --git a/test/e2e/specs/editor/various/block-renaming.spec.js b/test/e2e/specs/editor/various/block-renaming.spec.js index 8568258aaa4fda..1c8a958b23fd41 100644 --- a/test/e2e/specs/editor/various/block-renaming.spec.js +++ b/test/e2e/specs/editor/various/block-renaming.spec.js @@ -58,12 +58,16 @@ test.describe( 'Block Renaming', () => { name: 'Rename', } ); - // Check focus is transferred into modal. - await expect( renameModal ).toBeFocused(); - // Check the Modal is perceivable. await expect( renameModal ).toBeVisible(); + const nameInput = renameModal.getByRole( 'textbox', { + name: 'Block name', + } ); + + // Check focus is transferred into the input within the Modal. + await expect( nameInput ).toBeFocused(); + const saveButton = renameModal.getByRole( 'button', { name: 'Save', type: 'submit', @@ -71,8 +75,6 @@ test.describe( 'Block Renaming', () => { await expect( saveButton ).toBeDisabled(); - const nameInput = renameModal.getByLabel( 'Block name' ); - await expect( nameInput ).toHaveAttribute( 'placeholder', 'Group' ); await nameInput.fill( 'My new name' );