diff --git a/packages/block-editor/src/hooks/block-rename-ui.js b/packages/block-editor/src/hooks/block-rename-ui.js index 835a09556aed5..ec13198f4dd70 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="firstElement" >
{ __( 'Enter a custom name for this block.' ) }
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index d0e758045f481..89cd1e7f0212b 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### Breaking changes
+
+- Update `Modal` so that when passing `firstElement` as the `focusOnMount` prop it will optimize for focusing first element within the Modal's _contents_ as opposed to the entire component. ([#54296](https://github.com/WordPress/gutenberg/pull/54296)).
+
### Enhancements
- 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)).
diff --git a/packages/components/src/modal/README.md b/packages/components/src/modal/README.md
index 01cad7d6ff2e0..944fc6c384db8 100644
--- a/packages/components/src/modal/README.md
+++ b/packages/components/src/modal/README.md
@@ -189,7 +189,11 @@ Titles are required for accessibility reasons, see `aria.labelledby` and `title`
#### `focusOnMount`: `boolean | 'firstElement'`
-If this property is true, it will focus the first tabbable element rendered in the modal.
+If this property is true, it will focus the first tabbable element rendered anywhere within the modal.
+
+If the value `firstElement` is used then the component will attempt to place focus
+within the Modal's **contents**, initially skipping focusable nodes within the Modal's header. This is useful
+for Modal's which contain immediately focusable elements such as form fields.
- Required: No
- Default: `true`
diff --git a/packages/components/src/modal/index.tsx b/packages/components/src/modal/index.tsx
index d21a3f9ae3535..fafdb41ed49d9 100644
--- a/packages/components/src/modal/index.tsx
+++ b/packages/components/src/modal/index.tsx
@@ -39,6 +39,32 @@ import type { ModalProps } from './types';
// Used to count the number of open modals.
let openModalCount = 0;
+/**
+ * When `firstElement` is passed to `focusOnMount`, this function is optimized to
+ * avoid focusing on the `Close` button (or other "header" elements of the Modal
+ * and instead focus within the Modal's contents.
+ * However, if no tabbable elements are found within the Modal's contents, the
+ * first tabbable element (likely the `Close` button) will be focused instead.
+ * This ensures that at least one element is focused whilst still optimizing
+ * for the best a11y experience.
+ *
+ * See: https://github.com/WordPress/gutenberg/issues/54106.
+ * @param tabbables Element[] an array of tabbable elements.
+ * @return Element the first tabbable element in the Modal contents (or any tabbable element if none are found in content).
+ */
+function getFirstTabbableElement( tabbables: Element[] ) {
+ return (
+ // Attempt to locate tabbable outside of the header portion of the Modal.
+ tabbables.find(
+ ( tabbable ) =>
+ tabbable.closest( `.${ MODAL_HEADER_CLASSNAME }` ) === null
+ ) ??
+ // Fallback to the first tabbable element anywhere within the Modal.
+ // Likely the `Close` button.
+ tabbables[ 0 ]
+ );
+}
+
function UnforwardedModal(
props: ModalProps,
forwardedRef: ForwardedRef< HTMLDivElement >
@@ -75,7 +101,13 @@ function UnforwardedModal(
const headingId = title
? `components-modal-header-${ instanceId }`
: aria.labelledby;
- const focusOnMountRef = useFocusOnMount( focusOnMount );
+
+ // If focusOnMount is `firstElement`, Modals should ignore the `Close` button which is the first focusable element.
+ // Remap `true` to select the next focusable element instead.
+ const focusOnMountRef = useFocusOnMount(
+ focusOnMount === 'firstElement' ? getFirstTabbableElement : focusOnMount
+ );
+
const constrainedTabbingRef = useConstrainedTabbing();
const focusReturnRef = useFocusReturn();
const focusOutsideProps = useFocusOutside( onRequestClose );
diff --git a/packages/components/src/modal/stories/index.story.tsx b/packages/components/src/modal/stories/index.story.tsx
index 8405a6eb0113e..fe43db1ad691d 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' ],
+ 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 c2ab277f72157..93027af900c1a 100644
--- a/packages/components/src/modal/test/index.tsx
+++ b/packages/components/src/modal/test/index.tsx
@@ -129,4 +129,178 @@ describe( 'Modal', () => {
screen.getByText( 'A sweet button', { selector: 'button' } )
).toBeInTheDocument();
} );
+
+ describe( 'Focus handling', () => {
+ let originalGetClientRects: () => DOMRectList;
+
+ 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 first focusable element in the contents (if found) when `firstElement` passed as value for `focusOnMount` prop', async () => {
+ const user = userEvent.setup();
+
+ const FocusMountDemo = () => {
+ const [ isShown, setIsShown ] = useState( false );
+ return (
+ <>
+
+ { isShown && (
+ Modal content Modal content with no focusable elements. Modal content Modal content