From ec83e45992b3fb11e4c668a85c7dd663a22b480d Mon Sep 17 00:00:00 2001 From: Jerry Jones Date: Thu, 2 Nov 2023 11:17:21 -0500 Subject: [PATCH] Escape on Block Toolbar returns focus to Editor Canvas (#55712) * Move lastFocus into redux store Stores the element that had last focus from the editor when focus leaves the editor canvas * ForwardRef for selected block tools Adding the escape keypress from the navigable block tools will require forwarding the ref from the BlockTools all the way down to the NavigableToolbar. * Handle escape on toolbar via toolbar event listener This method requires forwarding a ref from the editor level down to the navigable toolbar so that the escape unselect shortcut can be blocked and the navigable toolbar event listener will still fire. Blocking the global escape event shouldn't be necessary, but we have a combination of a few things all combining to create a situation where: - Because the block toolbar uses createPortal to populate the block toolbar fills, we can't rely on the React event bubbling to hit the onKeyDown listener for the block toolbar - Since we can't use the React tree, we use the DOM tree which _should_ handle the event bubbling correctly from a `createPortal` element. - This bubbles via the React tree, which hits this `unselect` escape keypress before the block toolbar DOM event listener has access to it. Also, this is better than attaching it to all of the children in the block toolbar because when new items are attached to the toolbar (such as via the bubblesVirtually slots or the apply/cancel buttons when cropping an image) we can still catch the events. Otherwise, those buttons are added after the mount, and the children don't receive the listener. * Refactor to object named notation * Pass selectedBlockToolsRef to fixed toolbar * Add keyboard navigation test to cover escape and alt+10 keypresses * Remove onIndexChange from useEffect dependency array I added it since it was passed in, but it is a callback and doesn't change. It was causing unnecssary rerenders that was messing with the focus position when selecting an item from a dropdown, such as when changing the heading level on a site title block. --- .../data/data-core-block-editor.md | 24 ++ .../block-tools/block-contextual-toolbar.js | 10 +- .../src/components/block-tools/index.js | 17 +- .../block-tools/selected-block-popover.js | 274 ++++++++++++++++++ .../block-tools/selected-block-tools.js | 14 +- .../src/components/navigable-toolbar/index.js | 116 ++++++-- .../components/writing-flow/use-tab-nav.js | 11 +- packages/block-editor/src/store/actions.js | 15 + packages/block-editor/src/store/reducer.js | 19 ++ packages/block-editor/src/store/selectors.js | 11 + .../editor/various/navigable-toolbar.spec.js | 95 ++++++ 11 files changed, 563 insertions(+), 43 deletions(-) create mode 100644 packages/block-editor/src/components/block-tools/selected-block-popover.js diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 7b0bd386daaf4..38a93552bcbef 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -588,6 +588,18 @@ _Properties_ - _isDisabled_ `boolean`: Whether or not the user should be prevented from inserting this item. - _frecency_ `number`: Heuristic that combines frequency and recency. +### getLastFocus + +Returns the element of the last element that had focus when focus left the editor canvas. + +_Parameters_ + +- _state_ `Object`: Block editor state. + +_Returns_ + +- `Object`: Element. + ### getLastMultiSelectedBlockClientId Returns the client ID of the last block in the multi-selection set, or null if there is no multi-selection. @@ -1651,6 +1663,18 @@ _Parameters_ - _clientId_ `string`: The block's clientId. - _hasControlledInnerBlocks_ `boolean`: True if the block's inner blocks are controlled. +### setLastFocus + +Action that sets the element that had focus when focus leaves the editor canvas. + +_Parameters_ + +- _lastFocus_ `Object`: The last focused element. + +_Returns_ + +- `Object`: Action object. + ### setNavigationMode Action that enables or disables the navigation mode. diff --git a/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js b/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js index fcec9d56b24a8..52a625a06be43 100644 --- a/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js +++ b/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js @@ -8,6 +8,7 @@ import classnames from 'classnames'; */ import { __ } from '@wordpress/i18n'; import { + forwardRef, useLayoutEffect, useEffect, useRef, @@ -31,7 +32,10 @@ import BlockToolbar from '../block-toolbar'; import { store as blockEditorStore } from '../../store'; import { useHasAnyBlockControls } from '../block-controls/use-has-block-controls'; -function BlockContextualToolbar( { focusOnMount, isFixed, ...props } ) { +function UnforwardBlockContextualToolbar( + { focusOnMount, isFixed, ...props }, + ref +) { // When the toolbar is fixed it can be collapsed const [ isCollapsed, setIsCollapsed ] = useState( false ); const toolbarButtonRef = useRef(); @@ -184,7 +188,9 @@ function BlockContextualToolbar( { focusOnMount, isFixed, ...props } ) { return ( + ) } { showEmptyBlockSideInserter && ( @@ -177,6 +191,7 @@ export default function BlockTools( { needed for navigation and zoom-out mode. */ } { ! showEmptyBlockSideInserter && hasSelectedBlock && ( diff --git a/packages/block-editor/src/components/block-tools/selected-block-popover.js b/packages/block-editor/src/components/block-tools/selected-block-popover.js new file mode 100644 index 0000000000000..f456d01e267d0 --- /dev/null +++ b/packages/block-editor/src/components/block-tools/selected-block-popover.js @@ -0,0 +1,274 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { forwardRef, useRef, useEffect } from '@wordpress/element'; +import { isUnmodifiedDefaultBlock } from '@wordpress/blocks'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useShortcut } from '@wordpress/keyboard-shortcuts'; + +/** + * Internal dependencies + */ +import BlockSelectionButton from './block-selection-button'; +import BlockContextualToolbar from './block-contextual-toolbar'; +import { store as blockEditorStore } from '../../store'; +import BlockPopover from '../block-popover'; +import useBlockToolbarPopoverProps from './use-block-toolbar-popover-props'; +import Inserter from '../inserter'; +import { useShouldContextualToolbarShow } from '../../utils/use-should-contextual-toolbar-show'; + +function selector( select ) { + const { + __unstableGetEditorMode, + hasMultiSelection, + isTyping, + getLastMultiSelectedBlockClientId, + } = select( blockEditorStore ); + + return { + editorMode: __unstableGetEditorMode(), + hasMultiSelection: hasMultiSelection(), + isTyping: isTyping(), + lastClientId: hasMultiSelection() + ? getLastMultiSelectedBlockClientId() + : null, + }; +} + +function UnforwardSelectedBlockPopover( + { + clientId, + rootClientId, + isEmptyDefaultBlock, + capturingClientId, + __unstablePopoverSlot, + __unstableContentRef, + }, + ref +) { + const { editorMode, hasMultiSelection, isTyping, lastClientId } = useSelect( + selector, + [] + ); + + const isInsertionPointVisible = useSelect( + ( select ) => { + const { + isBlockInsertionPointVisible, + getBlockInsertionPoint, + getBlockOrder, + } = select( blockEditorStore ); + + if ( ! isBlockInsertionPointVisible() ) { + return false; + } + + const insertionPoint = getBlockInsertionPoint(); + const order = getBlockOrder( insertionPoint.rootClientId ); + return order[ insertionPoint.index ] === clientId; + }, + [ clientId ] + ); + const isToolbarForced = useRef( false ); + const { shouldShowContextualToolbar, canFocusHiddenToolbar } = + useShouldContextualToolbarShow(); + + const { stopTyping } = useDispatch( blockEditorStore ); + + const showEmptyBlockSideInserter = + ! isTyping && editorMode === 'edit' && isEmptyDefaultBlock; + const shouldShowBreadcrumb = + ! hasMultiSelection && + ( editorMode === 'navigation' || editorMode === 'zoom-out' ); + + useShortcut( + 'core/block-editor/focus-toolbar', + () => { + isToolbarForced.current = true; + stopTyping( true ); + }, + { + isDisabled: ! canFocusHiddenToolbar, + } + ); + + useEffect( () => { + isToolbarForced.current = false; + } ); + + // Stores the active toolbar item index so the block toolbar can return focus + // to it when re-mounting. + const initialToolbarItemIndexRef = useRef(); + + useEffect( () => { + // Resets the index whenever the active block changes so this is not + // persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 + initialToolbarItemIndexRef.current = undefined; + }, [ clientId ] ); + + const popoverProps = useBlockToolbarPopoverProps( { + contentElement: __unstableContentRef?.current, + clientId, + } ); + + if ( showEmptyBlockSideInserter ) { + return ( + +
+ +
+
+ ); + } + + if ( shouldShowBreadcrumb || shouldShowContextualToolbar ) { + return ( + + { shouldShowContextualToolbar && ( + { + initialToolbarItemIndexRef.current = index; + } } + // Resets the index whenever the active block changes so + // this is not persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 + key={ clientId } + /> + ) } + { shouldShowBreadcrumb && ( + + ) } + + ); + } + + return null; +} + +const SelectedBlockPopover = forwardRef( UnforwardSelectedBlockPopover ); + +function wrapperSelector( select ) { + const { + getSelectedBlockClientId, + getFirstMultiSelectedBlockClientId, + getBlockRootClientId, + getBlock, + getBlockParents, + __experimentalGetBlockListSettingsForBlocks, + } = select( blockEditorStore ); + + const clientId = + getSelectedBlockClientId() || getFirstMultiSelectedBlockClientId(); + + if ( ! clientId ) { + return; + } + + const { name, attributes = {} } = getBlock( clientId ) || {}; + const blockParentsClientIds = getBlockParents( clientId ); + + // Get Block List Settings for all ancestors of the current Block clientId. + const parentBlockListSettings = __experimentalGetBlockListSettingsForBlocks( + blockParentsClientIds + ); + + // Get the clientId of the topmost parent with the capture toolbars setting. + const capturingClientId = blockParentsClientIds.find( + ( parentClientId ) => + parentBlockListSettings[ parentClientId ] + ?.__experimentalCaptureToolbars + ); + + return { + clientId, + rootClientId: getBlockRootClientId( clientId ), + name, + isEmptyDefaultBlock: + name && isUnmodifiedDefaultBlock( { name, attributes } ), + capturingClientId, + }; +} + +function UnforwardWrappedBlockPopover( + { __unstablePopoverSlot, __unstableContentRef }, + ref +) { + const selected = useSelect( wrapperSelector, [] ); + + if ( ! selected ) { + return null; + } + + const { + clientId, + rootClientId, + name, + isEmptyDefaultBlock, + capturingClientId, + } = selected; + + if ( ! name ) { + return null; + } + + return ( + + ); +} + +export default forwardRef( UnforwardWrappedBlockPopover ); diff --git a/packages/block-editor/src/components/block-tools/selected-block-tools.js b/packages/block-editor/src/components/block-tools/selected-block-tools.js index 8fa0ac97d5c94..48c446f579581 100644 --- a/packages/block-editor/src/components/block-tools/selected-block-tools.js +++ b/packages/block-editor/src/components/block-tools/selected-block-tools.js @@ -6,7 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useRef, useEffect } from '@wordpress/element'; +import { forwardRef, useRef, useEffect } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { useShortcut } from '@wordpress/keyboard-shortcuts'; @@ -21,11 +21,10 @@ import useBlockToolbarPopoverProps from './use-block-toolbar-popover-props'; import useSelectedBlockToolProps from './use-selected-block-tool-props'; import { useShouldContextualToolbarShow } from '../../utils/use-should-contextual-toolbar-show'; -export default function SelectedBlockTools( { - clientId, - showEmptyBlockSideInserter, - __unstableContentRef, -} ) { +function UnforwardSelectedBlockTools( + { clientId, showEmptyBlockSideInserter, __unstableContentRef }, + ref +) { const { capturingClientId, isInsertionPointVisible, @@ -102,6 +101,7 @@ export default function SelectedBlockTools( { > { shouldShowContextualToolbar && ( { - const tabbables = focus.tabbable.find( ref.current ); + const tabbables = focus.tabbable.find( toolbarRef.current ); const onlyToolbarItem = hasOnlyToolbarItem( tabbables ); if ( ! onlyToolbarItem ) { deprecated( 'Using custom components as toolbar controls', { @@ -73,7 +82,7 @@ function useIsAccessibleToolbar( ref ) { } ); } setIsAccessibleToolbar( onlyToolbarItem ); - }, [] ); + }, [ toolbarRef ] ); useLayoutEffect( () => { // Toolbar buttons may be rendered asynchronously, so we use @@ -81,28 +90,32 @@ function useIsAccessibleToolbar( ref ) { const observer = new window.MutationObserver( determineIsAccessibleToolbar ); - observer.observe( ref.current, { childList: true, subtree: true } ); + observer.observe( toolbarRef.current, { + childList: true, + subtree: true, + } ); return () => observer.disconnect(); - }, [ isAccessibleToolbar ] ); + }, [ determineIsAccessibleToolbar, isAccessibleToolbar, toolbarRef ] ); return isAccessibleToolbar; } -function useToolbarFocus( - ref, +function useToolbarFocus( { + toolbarRef, focusOnMount, isAccessibleToolbar, defaultIndex, onIndexChange, - shouldUseKeyboardFocusShortcut -) { + shouldUseKeyboardFocusShortcut, + focusEditorOnEscape, +} ) { // Make sure we don't use modified versions of this prop. const [ initialFocusOnMount ] = useState( focusOnMount ); const [ initialIndex ] = useState( defaultIndex ); const focusToolbar = useCallback( () => { - focusFirstTabbableIn( ref.current ); - }, [] ); + focusFirstTabbableIn( toolbarRef.current ); + }, [ toolbarRef ] ); const focusToolbarViaShortcut = () => { if ( shouldUseKeyboardFocusShortcut ) { @@ -121,7 +134,7 @@ function useToolbarFocus( useEffect( () => { // Store ref so we have access on useEffect cleanup: https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html#effect-cleanup-timing - const navigableToolbarRef = ref.current; + const navigableToolbarRef = toolbarRef.current; // If initialIndex is passed, we focus on that toolbar item when the // toolbar gets mounted and initial focus is not forced. // We have to wait for the next browser paint because block controls aren't @@ -150,32 +163,73 @@ function useToolbarFocus( const index = items.findIndex( ( item ) => item.tabIndex === 0 ); onIndexChange( index ); }; - }, [ initialIndex, initialFocusOnMount ] ); + }, [ initialIndex, initialFocusOnMount, toolbarRef ] ); + + const { lastFocus } = useSelect( ( select ) => { + const { getLastFocus } = select( blockEditorStore ); + return { + lastFocus: getLastFocus(), + }; + }, [] ); + /** + * Handles returning focus to the block editor canvas when pressing escape. + */ + useEffect( () => { + const navigableToolbarRef = toolbarRef.current; + + if ( focusEditorOnEscape ) { + const handleKeyDown = ( event ) => { + if ( event.keyCode === ESCAPE && lastFocus?.current ) { + // Focus the last focused element when pressing escape. + event.preventDefault(); + lastFocus.current.focus(); + } + }; + navigableToolbarRef.addEventListener( 'keydown', handleKeyDown ); + return () => { + navigableToolbarRef.removeEventListener( + 'keydown', + handleKeyDown + ); + }; + } + }, [ focusEditorOnEscape, lastFocus, toolbarRef ] ); } -function NavigableToolbar( { - children, - focusOnMount, - shouldUseKeyboardFocusShortcut = true, - __experimentalInitialIndex: initialIndex, - __experimentalOnIndexChange: onIndexChange, - ...props -} ) { - const ref = useRef(); - const isAccessibleToolbar = useIsAccessibleToolbar( ref ); +function UnforwardNavigableToolbar( + { + children, + focusOnMount, + focusEditorOnEscape = false, + shouldUseKeyboardFocusShortcut = true, + __experimentalInitialIndex: initialIndex, + __experimentalOnIndexChange: onIndexChange, + ...props + }, + ref +) { + const maybeRef = useRef(); + // If a ref was not forwarded, we create one. + const toolbarRef = ref || maybeRef; + const isAccessibleToolbar = useIsAccessibleToolbar( toolbarRef ); - useToolbarFocus( - ref, + useToolbarFocus( { + toolbarRef, focusOnMount, isAccessibleToolbar, - initialIndex, + defaultIndex: initialIndex, onIndexChange, - shouldUseKeyboardFocusShortcut - ); + shouldUseKeyboardFocusShortcut, + focusEditorOnEscape, + } ); if ( isAccessibleToolbar ) { return ( - + { children } ); @@ -185,7 +239,7 @@ function NavigableToolbar( { { children } @@ -193,4 +247,4 @@ function NavigableToolbar( { ); } -export default NavigableToolbar; +export default forwardRef( UnforwardNavigableToolbar ); diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js index 616da1bc75813..b1fb1800a53ea 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js @@ -17,15 +17,20 @@ export default function useTabNav() { const container = useRef(); const focusCaptureBeforeRef = useRef(); const focusCaptureAfterRef = useRef(); - const lastFocus = useRef(); + const { hasMultiSelection, getSelectedBlockClientId, getBlockCount } = useSelect( blockEditorStore ); - const { setNavigationMode } = useDispatch( blockEditorStore ); + const { setNavigationMode, setLastFocus } = useDispatch( blockEditorStore ); const isNavigationMode = useSelect( ( select ) => select( blockEditorStore ).isNavigationMode(), [] ); + const lastFocus = useSelect( + ( select ) => select( blockEditorStore ).getLastFocus(), + [] + ); + // Don't allow tabbing to this element in Navigation mode. const focusCaptureTabIndex = ! isNavigationMode ? '0' : undefined; @@ -158,7 +163,7 @@ export default function useTabNav() { } function onFocusOut( event ) { - lastFocus.current = event.target; + setLastFocus( { ...lastFocus, current: event.target } ); const { ownerDocument } = node; diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 2975a41dbb9d9..4f6ce7b5b044c 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1980,3 +1980,18 @@ export function unsetBlockEditingMode( clientId = '' ) { clientId, }; } + +/** + * Action that sets the element that had focus when focus leaves the editor canvas. + * + * @param {Object} lastFocus The last focused element. + * + * + * @return {Object} Action object. + */ +export function setLastFocus( lastFocus = null ) { + return { + type: 'LAST_FOCUS', + lastFocus, + }; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 4373182d98662..d5ff85e9e4257 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1962,6 +1962,24 @@ export function registeredInserterMediaCategories( state = [], action ) { case 'REGISTER_INSERTER_MEDIA_CATEGORY': return [ ...state, action.category ]; } + + return state; +} + +/** + * Reducer setting last focused element + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function lastFocus( state = false, action ) { + switch ( action.type ) { + case 'LAST_FOCUS': + return action.lastFocus; + } + return state; } @@ -1981,6 +1999,7 @@ const combinedReducers = combineReducers( { settings, preferences, lastBlockAttributesChange, + lastFocus, editorMode, hasBlockMovingClientId, highlightedBlock, diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index cb1f8ef49809d..e9d17d86a2672 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -3022,3 +3022,14 @@ export const isGroupable = createRegistrySelector( ); } ); + +/** + * Returns the element of the last element that had focus when focus left the editor canvas. + * + * @param {Object} state Block editor state. + * + * @return {Object} Element. + */ +export function getLastFocus( state ) { + return state.lastFocus; +} diff --git a/test/e2e/specs/editor/various/navigable-toolbar.spec.js b/test/e2e/specs/editor/various/navigable-toolbar.spec.js index abdb1800d150a..0f9438ac946dd 100644 --- a/test/e2e/specs/editor/various/navigable-toolbar.spec.js +++ b/test/e2e/specs/editor/various/navigable-toolbar.spec.js @@ -3,6 +3,12 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +test.use( { + BlockToolbarUtils: async ( { page, pageUtils }, use ) => { + await use( new BlockToolbarUtils( { page, pageUtils } ) ); + }, +} ); + test.describe( 'Block Toolbar', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); @@ -44,6 +50,67 @@ test.describe( 'Block Toolbar', () => { } ); expect( scrollTopBefore ).toBe( scrollTopAfter ); } ); + + test( 'can navigate to the block toolbar and back to block using the keyboard', async ( { + BlockToolbarUtils, + editor, + page, + pageUtils, + } ) => { + // Test navigating to block toolbar + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'Paragraph' ); + await BlockToolbarUtils.focusBlockToolbar(); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Paragraph' ); + // // Navigate to Align Text + await page.keyboard.press( 'ArrowRight' ); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Align text' ); + // // Open the dropdown + await page.keyboard.press( 'Enter' ); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Align text left' ); + await page.keyboard.press( 'ArrowDown' ); + await BlockToolbarUtils.expectLabelToHaveFocus( + 'Align text center' + ); + await page.keyboard.press( 'Escape' ); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Align text' ); + + // Navigate to the Bold item. Testing items via the fills within the block toolbar are especially important + await page.keyboard.press( 'ArrowRight' ); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Bold' ); + + await BlockToolbarUtils.focusBlock(); + await BlockToolbarUtils.expectLabelToHaveFocus( + 'Block: Paragraph' + ); + + await BlockToolbarUtils.focusBlockToolbar(); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Bold' ); + + await BlockToolbarUtils.focusBlock(); + + // Try selecting text and navigating to block toolbar + await pageUtils.pressKeys( 'Shift+ArrowLeft', { + times: 4, + delay: 50, + } ); + expect( + await editor.canvas + .locator( ':root' ) + .evaluate( () => window.getSelection().toString() ) + ).toBe( 'raph' ); + + // Go back to the toolbar and apply a formatting option + await BlockToolbarUtils.focusBlockToolbar(); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Bold' ); + await page.keyboard.press( 'Enter' ); + // Should focus the selected text again + expect( + await editor.canvas + .locator( ':root' ) + .evaluate( () => window.getSelection().toString() ) + ).toBe( 'raph' ); + } ); } ); test( 'should focus with Shift+Tab', async ( { @@ -61,3 +128,31 @@ test.describe( 'Block Toolbar', () => { ).toBeFocused(); } ); } ); + +class BlockToolbarUtils { + constructor( { page, pageUtils } ) { + this.page = page; + this.pageUtils = pageUtils; + } + + async focusBlockToolbar() { + await this.pageUtils.pressKeys( 'alt+F10' ); + } + + async focusBlock() { + await this.pageUtils.pressKeys( 'Escape' ); + } + + async expectLabelToHaveFocus( label ) { + const ariaLabel = await this.page.evaluate( () => { + const { activeElement } = + document.activeElement.contentDocument ?? document; + return ( + activeElement.getAttribute( 'aria-label' ) || + activeElement.innerText + ); + } ); + + expect( ariaLabel ).toBe( label ); + } +}