Skip to content

Commit a0d00ca

Browse files
authored
Allow Modal to place focus on first element within contents via new API (#54590)
* Add new firstContentElement variation to Modal * Update Story * Update e2e test * Add focus tests * Conditionally add ref * Provide comment to explain unusual code * Remove extra div element * Update docs * Tweak code comment documentation * Add test for firstElement * Refactor tests * Prefer getByRole * Improve README with additional context
1 parent 8385ae7 commit a0d00ca

File tree

8 files changed

+172
-11
lines changed

8 files changed

+172
-11
lines changed

packages/block-editor/src/hooks/block-rename-ui.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ function RenameModal( { blockName, originalBlockName, onClose, onSave } ) {
6969
aria={ {
7070
describedby: dialogDescription,
7171
} }
72+
focusOnMount="firstContentElement"
7273
>
7374
<p id={ dialogDescription }>
7475
{ __( 'Enter a custom name for this block.' ) }

packages/components/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
### Enhancements
1313

14+
- 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)).
1415
- `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)).
1516
- 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)).
1617
- `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)).

packages/components/src/modal/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,16 @@ Titles are required for accessibility reasons, see `aria.labelledby` and `title`
187187

188188
- Required: No
189189

190-
#### `focusOnMount`: `boolean | 'firstElement'`
190+
#### `focusOnMount`: `boolean | 'firstElement'` | 'firstContentElement'
191191

192192
If this property is true, it will focus the first tabbable element rendered in the modal.
193193

194+
If this property is false, focus will not be transferred and it is the responsibility of the consumer to ensure accessible focus management.
195+
196+
If set to `firstElement` focus will be placed on the first tabbable element anywhere within the Modal.
197+
198+
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**.
199+
194200
- Required: No
195201
- Default: `true`
196202

packages/components/src/modal/index.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,23 @@ function UnforwardedModal(
7171
} = props;
7272

7373
const ref = useRef< HTMLDivElement >();
74+
7475
const instanceId = useInstanceId( Modal );
7576
const headingId = title
7677
? `components-modal-header-${ instanceId }`
7778
: aria.labelledby;
78-
const focusOnMountRef = useFocusOnMount( focusOnMount );
79+
80+
// The focus hook does not support 'firstContentElement' but this is a valid
81+
// value for the Modal's focusOnMount prop. The following code ensures the focus
82+
// hook will focus the first focusable node within the element to which it is applied.
83+
// When `firstContentElement` is passed as the value of the focusOnMount prop,
84+
// the focus hook is applied to the Modal's content element.
85+
// Otherwise, the focus hook is applied to the Modal's ref. This ensures that the
86+
// focus hook will focus the first element in the Modal's **content** when
87+
// `firstContentElement` is passed.
88+
const focusOnMountRef = useFocusOnMount(
89+
focusOnMount === 'firstContentElement' ? 'firstElement' : focusOnMount
90+
);
7991
const constrainedTabbingRef = useConstrainedTabbing();
8092
const focusReturnRef = useFocusReturn();
8193
const focusOutsideProps = useFocusOutside( onRequestClose );
@@ -223,7 +235,9 @@ function UnforwardedModal(
223235
ref={ useMergeRefs( [
224236
constrainedTabbingRef,
225237
focusReturnRef,
226-
focusOnMountRef,
238+
focusOnMount !== 'firstContentElement'
239+
? focusOnMountRef
240+
: null,
227241
] ) }
228242
role={ role }
229243
aria-label={ contentLabel }
@@ -283,7 +297,17 @@ function UnforwardedModal(
283297
) }
284298
</div>
285299
) }
286-
<div ref={ childrenContainerRef }>{ children }</div>
300+
301+
<div
302+
ref={ useMergeRefs( [
303+
childrenContainerRef,
304+
focusOnMount === 'firstContentElement'
305+
? focusOnMountRef
306+
: null,
307+
] ) }
308+
>
309+
{ children }
310+
</div>
287311
</div>
288312
</div>
289313
</StyleProvider>

packages/components/src/modal/stories/index.story.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ const meta: Meta< typeof Modal > = {
2828
control: { type: null },
2929
},
3030
focusOnMount: {
31-
control: { type: 'boolean' },
31+
options: [ true, false, 'firstElement', 'firstContentElement' ],
32+
control: { type: 'select' },
3233
},
3334
role: {
3435
control: { type: 'text' },

packages/components/src/modal/test/index.tsx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useState } from '@wordpress/element';
1313
* Internal dependencies
1414
*/
1515
import Modal from '../';
16+
import type { ModalProps } from '../types';
1617

1718
const noop = () => {};
1819

@@ -236,4 +237,127 @@ describe( 'Modal', () => {
236237
screen.getByText( 'A sweet button', { selector: 'button' } )
237238
).toBeInTheDocument();
238239
} );
240+
241+
describe( 'Focus handling', () => {
242+
let originalGetClientRects: () => DOMRectList;
243+
244+
const FocusMountDemo = ( {
245+
focusOnMount,
246+
}: Pick< ModalProps, 'focusOnMount' > ) => {
247+
const [ isShown, setIsShown ] = useState( false );
248+
return (
249+
<>
250+
<button onClick={ () => setIsShown( true ) }>
251+
Toggle Modal
252+
</button>
253+
{ isShown && (
254+
<Modal
255+
focusOnMount={ focusOnMount }
256+
onRequestClose={ () => setIsShown( false ) }
257+
>
258+
<p>Modal content</p>
259+
<a href="https://wordpress.org">
260+
First Focusable Content Element
261+
</a>
262+
263+
<a href="https://wordpress.org">
264+
Another Focusable Content Element
265+
</a>
266+
</Modal>
267+
) }
268+
</>
269+
);
270+
};
271+
272+
beforeEach( () => {
273+
/**
274+
* The test environment does not have a layout engine, so we need to mock
275+
* the getClientRects method. This ensures that the focusable elements can be
276+
* found by the `focusOnMount` logic which depends on layout information
277+
* to determine if the element is visible or not.
278+
* See https://github.com/WordPress/gutenberg/blob/trunk/packages/dom/src/focusable.js#L55-L61.
279+
*/
280+
// @ts-expect-error We're not trying to comply to the DOM spec, only mocking
281+
window.HTMLElement.prototype.getClientRects = function () {
282+
return [ 'trick-jsdom-into-having-size-for-element-rect' ];
283+
};
284+
} );
285+
286+
afterEach( () => {
287+
// Restore original HTMLElement prototype.
288+
// See beforeEach for details.
289+
window.HTMLElement.prototype.getClientRects =
290+
originalGetClientRects;
291+
} );
292+
293+
it( 'should focus the Modal dialog by default when `focusOnMount` prop is not provided', async () => {
294+
const user = userEvent.setup();
295+
296+
render( <FocusMountDemo /> );
297+
298+
const opener = screen.getByRole( 'button', {
299+
name: 'Toggle Modal',
300+
} );
301+
302+
await user.click( opener );
303+
304+
expect( screen.getByRole( 'dialog' ) ).toHaveFocus();
305+
} );
306+
307+
it( 'should focus the Modal dialog when `true` passed as value for `focusOnMount` prop', async () => {
308+
const user = userEvent.setup();
309+
310+
render( <FocusMountDemo focusOnMount={ true } /> );
311+
312+
const opener = screen.getByRole( 'button', {
313+
name: 'Toggle Modal',
314+
} );
315+
316+
await user.click( opener );
317+
318+
expect( screen.getByRole( 'dialog' ) ).toHaveFocus();
319+
} );
320+
321+
it( 'should focus the first focusable element in the contents (if found) when `firstContentElement` passed as value for `focusOnMount` prop', async () => {
322+
const user = userEvent.setup();
323+
324+
render( <FocusMountDemo focusOnMount="firstContentElement" /> );
325+
326+
const opener = screen.getByRole( 'button' );
327+
328+
await user.click( opener );
329+
330+
expect(
331+
screen.getByText( 'First Focusable Content Element' )
332+
).toHaveFocus();
333+
} );
334+
335+
it( 'should focus the first element anywhere within the Modal when `firstElement` passed as value for `focusOnMount` prop', async () => {
336+
const user = userEvent.setup();
337+
338+
render( <FocusMountDemo focusOnMount="firstElement" /> );
339+
340+
const opener = screen.getByRole( 'button' );
341+
342+
await user.click( opener );
343+
344+
expect(
345+
screen.getByRole( 'button', { name: 'Close' } )
346+
).toHaveFocus();
347+
} );
348+
349+
it( 'should not move focus when `false` passed as value for `focusOnMount` prop', async () => {
350+
const user = userEvent.setup();
351+
352+
render( <FocusMountDemo focusOnMount={ false } /> );
353+
354+
const opener = screen.getByRole( 'button', {
355+
name: 'Toggle Modal',
356+
} );
357+
358+
await user.click( opener );
359+
360+
expect( opener ).toHaveFocus();
361+
} );
362+
} );
239363
} );

packages/components/src/modal/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ export type ModalProps = {
6868
*
6969
* @default true
7070
*/
71-
focusOnMount?: Parameters< typeof useFocusOnMount >[ 0 ];
71+
focusOnMount?:
72+
| Parameters< typeof useFocusOnMount >[ 0 ]
73+
| 'firstContentElement';
7274
/**
7375
* Elements that are injected into the modal header to the left of the close button (if rendered).
7476
* Hidden if `__experimentalHideHeader` is `true`.

test/e2e/specs/editor/various/block-renaming.spec.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,21 +58,23 @@ test.describe( 'Block Renaming', () => {
5858
name: 'Rename',
5959
} );
6060

61-
// Check focus is transferred into modal.
62-
await expect( renameModal ).toBeFocused();
63-
6461
// Check the Modal is perceivable.
6562
await expect( renameModal ).toBeVisible();
6663

64+
const nameInput = renameModal.getByRole( 'textbox', {
65+
name: 'Block name',
66+
} );
67+
68+
// Check focus is transferred into the input within the Modal.
69+
await expect( nameInput ).toBeFocused();
70+
6771
const saveButton = renameModal.getByRole( 'button', {
6872
name: 'Save',
6973
type: 'submit',
7074
} );
7175

7276
await expect( saveButton ).toBeDisabled();
7377

74-
const nameInput = renameModal.getByLabel( 'Block name' );
75-
7678
await expect( nameInput ).toHaveAttribute( 'placeholder', 'Group' );
7779

7880
await nameInput.fill( 'My new name' );

0 commit comments

Comments
 (0)