diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js index a62d4278070abd..56ee50e8214689 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js @@ -85,26 +85,47 @@ export function useFocusFirstElement( { clientId, initialPosition } ) { } } // Do not place a caret when the target already contains one: - // while a focused editing host contains the target (the block - // supports `editableRoot`), the caret can be inside it without the - // target holding focus. Only a caret the rich text synchronized to - // the store (offsets present) is deliberate; a leftover one yields - // to an explicitly requested edge position (initialPosition -1). - const { activeElement } = ownerDocument; + // while an editing host contains the target (the block supports + // `editableRoot`), the caret can be inside it without the target + // holding focus. Only a caret the rich text synchronized to the + // store (offsets present) is deliberate; a leftover one yields to + // an explicitly requested edge position (initialPosition -1). const selection = ownerDocument.defaultView.getSelection(); const { clientId: selectionClientId, offset } = getSelectionStart(); const hasCaret = - activeElement?.isContentEditable && - activeElement.contains( target ) && - !! selection.anchorNode && - target.contains( selection.anchorNode ); + !! selection.anchorNode && target.contains( selection.anchorNode ); const isDeliberate = initialPosition === 0 || ( offset !== undefined && selectionClientId === clientId ); - if ( ! ( hasCaret && isDeliberate ) ) { - placeCaretAtHorizontalEdge( target, isReverse ); + if ( hasCaret && isDeliberate ) { + // The field applies the store selection when focused, so only + // move focus, unless an editing host containing the target has + // it. + const { activeElement } = ownerDocument; + const isHosted = + activeElement?.isContentEditable && + activeElement.contains( target ); + if ( ! isHosted ) { + target.focus(); + } + return; + } + + // Under the editing host the target is editable through the host + // and not a focus target of its own (no contenteditable attribute): + // place the caret at its edge directly. Focusing it then focuses + // the host (see RichText), which adopts the caret. + if ( target.isContentEditable && target.contentEditable !== 'true' ) { + selection.collapse( + target, + isReverse ? target.childNodes.length : 0 + ); + target.focus(); + return; } + + placeCaretAtHorizontalEdge( target, isReverse ); }, [ initialPosition, clientId ] ); return ref; diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js b/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js index 637b57da54cbde..62d70acdf5f8ad 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js @@ -3,7 +3,7 @@ import { useRefEffect, privateApis as composePrivateApis, } from '@wordpress/compose'; -import { isInsideRootBlock } from '../../../utils/dom'; +import { isInsideRootBlock, getBlockClientId } from '../../../utils/dom'; import { store as blockEditorStore } from '../../../store'; import { unlock } from '../../../lock-unlock'; @@ -74,6 +74,21 @@ export function useFocusHandler( clientId ) { // builds the multi-selection from the anchor it recorded at // mousedown, so this dispatch overwriting the store anchor // is harmless. + // A click on the selected block's inert field can focus this + // wrapper instead (Firefox focuses the nearest focusable + // ancestor). Under an engaged editing host, a caret in the + // selected block within this one keeps that selection. + const { anchorNode } = + node.ownerDocument.defaultView.getSelection(); + if ( + anchorNode && + node.contains( anchorNode ) && + isBlockSelected( getBlockClientId( anchorNode ) ) && + node.isContentEditable + ) { + return; + } + if ( event.target.isContentEditable ) { selectBlock( clientId, null ); return; diff --git a/packages/block-editor/src/components/rich-text/index.jsx b/packages/block-editor/src/components/rich-text/index.jsx index 98696b7a9e6a43..e12c8b16c957f4 100644 --- a/packages/block-editor/src/components/rich-text/index.jsx +++ b/packages/block-editor/src/components/rich-text/index.jsx @@ -11,7 +11,7 @@ import { useContext, } from '@wordpress/element'; import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; -import { useMergeRefs, useInstanceId } from '@wordpress/compose'; +import { useMergeRefs, useInstanceId, useRefEffect } from '@wordpress/compose'; import { privateApis as richTextPrivateApis } from '@wordpress/rich-text'; import { Popover } from '@wordpress/components'; import { getBlockBindingsSource } from '@wordpress/blocks'; @@ -239,25 +239,33 @@ function RichTextWrapper( const shouldDisableEditing = readOnly || disableBoundBlock || shouldDisableForPattern; - // Whether the wrapper is the editing host, which depends on the selected - // block, not necessarily this one. Only the selected, default-mode block - // can be it, so others skip the subscription entirely. + // Whether the wrapper hosts editing for this block: the selected block + // can host it, and any block in a multi-selection is inside the host's + // range and must not be an editing area of its own there. const isEditingHost = useSelect( ( select ) => { - if ( - shouldDisableEditing || - ! hasDefaultEditingMode || - ! isBlockSelected - ) { + if ( shouldDisableEditing || ! hasDefaultEditingMode ) { return false; } - const { getSelectedBlockClientId, canHostEditableRoot } = unlock( - select( blockEditorStore ) - ); - return canHostEditableRoot( getSelectedBlockClientId() ); + const { + getSelectedBlockClientId, + canHostEditableRoot, + isBlockMultiSelected, + } = unlock( select( blockEditorStore ) ); + + if ( isBlockSelected ) { + return canHostEditableRoot( getSelectedBlockClientId() ); + } + + return isBlockMultiSelected( clientId ); }, - [ shouldDisableEditing, hasDefaultEditingMode, isBlockSelected ] + [ + shouldDisableEditing, + hasDefaultEditingMode, + isBlockSelected, + clientId, + ] ); const { getSelectionStart, getSelectionEnd, getBlockRootClientId } = @@ -366,7 +374,7 @@ function RichTextWrapper( // A pointer press outside the field makes it non editable until the // release (see rich text's preventFocusCapture). Focusing it then // makes the block focus handler drop the text selection. - if ( ! isSelected || element?.contentEditable !== 'true' ) { + if ( ! isSelected || element?.contentEditable === 'false' ) { return; } @@ -462,6 +470,59 @@ function RichTextWrapper( anchorRef.current?.focus(); } + // Under the editing host the element is not a focus target (no tabindex + // and no contenteditable attribute, see below), so `focus()` on it would + // do nothing. Keep it working: place the caret in the element and focus + // the host instead. + const focusUnderHostRef = useRefEffect( + ( element ) => { + if ( ! isEditingHost ) { + return; + } + + const { ownerDocument } = element; + const { focus: nativeFocus } = element; + + element.focus = ( options ) => { + const host = element.parentElement?.closest( + '[contenteditable="true"]' + ); + + // The host disengaged in this commit, ahead of this + // override's removal: the element is a focus target again. + if ( ! host ) { + nativeFocus.call( element, options ); + return; + } + + const selection = ownerDocument.defaultView.getSelection(); + + if ( ! element.contains( selection.anchorNode ) ) { + selection.collapse( element, 0 ); + } + + if ( + ownerDocument.activeElement !== host || + ! ownerDocument.hasFocus() + ) { + const range = selection.getRangeAt( 0 ).cloneRange(); + host.focus( { preventScroll: true, ...options } ); + // Gecko moves the selection when an editing host takes + // focus instead of adopting the one within it. + if ( ! element.contains( selection.anchorNode ) ) { + selection.removeAllRanges(); + selection.addRange( range ); + } + } + }; + + return () => { + delete element.focus; + }; + }, + [ isEditingHost ] + ); + // Setting tabIndex to 0 is unnecessary, the element is already focusable // because it's contentEditable. This also fixes a Safari bug where it's // not possible to Shift+Click multi select blocks when Shift Clicking @@ -473,7 +534,11 @@ function RichTextWrapper( // focusability. let tabIndex = props.tabIndex; if ( isEditingHost ) { - tabIndex = props.tabIndex ?? 0; + // The field must not be a focus target under the host: iOS focuses a + // focusable child on tap, thrashing focus with the host and canceling + // native selection gestures (double tap to select a word). Block + // props pass tabIndex 0, so remove it explicitly. + tabIndex = null; } else if ( ! shouldDisableEditing && props.tabIndex === 0 ) { tabIndex = null; } @@ -554,8 +619,15 @@ function RichTextWrapper( } ), anchorRef, setAnchorElement, + focusUnderHostRef, ] ) } - contentEditable={ ! shouldDisableEditing } + contentEditable={ + // Under the editing host the field is editable through the + // host, not an editing host of its own. The attribute must + // be absent, not "inherit": Gecko treats the invalid value + // as non-editable. + isEditingHost ? undefined : ! shouldDisableEditing + } suppressContentEditableWarning className={ clsx( 'block-editor-rich-text__editable', diff --git a/packages/block-editor/src/components/typewriter/index.jsx b/packages/block-editor/src/components/typewriter/index.jsx index 88f72d5f6e9268..236109036a6057 100644 --- a/packages/block-editor/src/components/typewriter/index.jsx +++ b/packages/block-editor/src/components/typewriter/index.jsx @@ -100,16 +100,14 @@ export function useTypewriter() { : ( caretRect.top - scrollContainerY ) / ( defaultView.innerHeight - scrollContainerY ); - // If the scroll position is at the start, the active editable element - // is the last one, and the caret is positioned within the initial - // trigger percentage of the page, do not scroll the page. - // The typewriter effect should not kick in until an empty page has been - // filled with the initial trigger percentage or the user scrolls - // intentionally down. + // If the scroll position is at the start and the caret is + // positioned within the initial trigger percentage of the page, do + // not scroll the page. The typewriter effect should not kick in + // until an empty page has been filled with the initial trigger + // percentage or the user scrolls intentionally down. if ( scrollY === 0 && - relativeScrollPosition < initialTriggerPercentage && - isLastEditableNode() + relativeScrollPosition < initialTriggerPercentage ) { // Reset the caret position to maintain. caretRect = currentCaretRect; @@ -174,58 +172,22 @@ export function useTypewriter() { } /** - * Returns the editable element owning the selection: the active - * element, or, when a focused editing host contains the node - * (a selected block supports `editableRoot`), the editable - * element containing the selection. + * Checks if the current situation is eligible for scroll: + * - The component must contain the selection. + * - The selection must be within editable content. */ - function getActiveEditableElement() { - const { activeElement } = ownerDocument; - - if ( ! activeElement ) { - return null; - } - - if ( - ! activeElement.isContentEditable || - ! activeElement.contains( node ) - ) { - return activeElement; - } - + function isSelectionEligibleForScroll() { const { anchorNode } = defaultView.getSelection(); - if ( ! anchorNode ) { - return null; + if ( ! anchorNode || ! node.contains( anchorNode ) ) { + return false; } const element = anchorNode.nodeType === anchorNode.ELEMENT_NODE ? anchorNode : anchorNode.parentElement; - return element?.closest( '[contenteditable="true"]' ) ?? null; - } - - /** - * Checks if the current situation is eligible for scroll: - * - The component must contain the selection. - * - The active element must be contenteditable. - */ - function isSelectionEligibleForScroll() { - const activeEditableElement = getActiveEditableElement(); - return ( - !! activeEditableElement && - node.contains( activeEditableElement ) && - activeEditableElement.isContentEditable - ); - } - - function isLastEditableNode() { - const editableNodes = node.querySelectorAll( - '[contenteditable="true"]' - ); - const lastEditableNode = editableNodes[ editableNodes.length - 1 ]; - return lastEditableNode === getActiveEditableElement(); + return !! element?.isContentEditable; } // When the user scrolls or resizes, the scroll position should be diff --git a/packages/block-editor/src/components/writing-flow/use-arrow-nav.js b/packages/block-editor/src/components/writing-flow/use-arrow-nav.js index 83ca7482738a31..a1e21d3d795c2b 100644 --- a/packages/block-editor/src/components/writing-flow/use-arrow-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-arrow-nav.js @@ -100,12 +100,24 @@ export function getClosestTabbable( focusableNodes.reverse(); } - // Consider as candidates those focusables after the current target. It's - // assumed this can only be reached if the target is focusable (on its - // keydown event), so no need to verify it exists in the set. - focusableNodes = focusableNodes.slice( - focusableNodes.indexOf( target ) + 1 - ); + // Consider as candidates those focusables after the current target. + const targetIndex = focusableNodes.indexOf( target ); + + if ( targetIndex !== -1 ) { + focusableNodes = focusableNodes.slice( targetIndex + 1 ); + } else { + // The target is not focusable (the selected block's editable under + // the editing host has no tabindex): take the focusables on the + // navigation side of it in document order, not its descendants. + focusableNodes = focusableNodes.filter( ( focusableNode ) => { + const position = target.compareDocumentPosition( focusableNode ); + const mask = isReverse + ? target.DOCUMENT_POSITION_PRECEDING + : target.DOCUMENT_POSITION_FOLLOWING; + // eslint-disable-next-line no-bitwise + return !! ( position & mask ) && ! target.contains( focusableNode ); + } ); + } let targetRect; diff --git a/packages/block-editor/src/components/writing-flow/use-click-selection.js b/packages/block-editor/src/components/writing-flow/use-click-selection.js index f5d2c63aef5f36..a671fc9ed8d409 100644 --- a/packages/block-editor/src/components/writing-flow/use-click-selection.js +++ b/packages/block-editor/src/components/writing-flow/use-click-selection.js @@ -3,6 +3,7 @@ import { useRefEffect } from '@wordpress/compose'; import { store as blockEditorStore } from '../../store'; import { setContentEditableWrapper } from './utils'; import { getBlockClientId } from '../../utils/dom'; +import { unlock } from '../../lock-unlock'; export default function useClickSelection() { const { selectBlock } = useDispatch( blockEditorStore ); @@ -11,7 +12,8 @@ export default function useClickSelection() { getBlockSelectionStart, getSelectionStart, hasMultiSelection, - } = useSelect( blockEditorStore ); + canHostEditableRoot, + } = unlock( useSelect( blockEditorStore ) ); return useRefEffect( ( node ) => { function onMouseDown( event ) { @@ -84,6 +86,31 @@ export default function useClickSelection() { // multiselection (focus moved to first block's multi- // controls). selectBlock( clickedClientId ); + } else if ( + clickedClientId && + clickedClientId !== startClientId && + canHostEditableRoot( clickedClientId ) + ) { + // Selecting the block makes its field inert under the + // engaged wrapper. Left to the re-render, that lands + // mid-click, after the browser placed the caret and focus + // in the field, and drops both. Do it now, so the default + // action places the caret through the host instead. + const editable = event.target.closest( + '[contenteditable="true"]' + ); + + if ( + editable && + getBlockClientId( editable ) === clickedClientId + ) { + setContentEditableWrapper( node, true ); + // Remove the attribute rather than set "inherit": + // Gecko does not map the invalid value to the inherit + // state and treats the element as non-editable. + editable.removeAttribute( 'contenteditable' ); + selectBlock( clickedClientId, null ); + } } } diff --git a/packages/block-editor/src/components/writing-flow/use-drag-selection.js b/packages/block-editor/src/components/writing-flow/use-drag-selection.js index 3354ba5d0c1e78..ed56b70f506a89 100644 --- a/packages/block-editor/src/components/writing-flow/use-drag-selection.js +++ b/packages/block-editor/src/components/writing-flow/use-drag-selection.js @@ -15,6 +15,7 @@ export default function useDragSelection() { hasSelectedBlock, isDraggingBlocks, isMultiSelecting, + getSelectedBlockClientId, } = useSelect( blockEditorStore ); return useRefEffect( ( node ) => { @@ -100,16 +101,15 @@ export default function useDragSelection() { return; } - // Check the attribute, not the contentEditable attribute. All - // child elements of the content editable wrapper are editable - // and return true for this property. We only want to start - // multi selecting when the mouse leaves the wrapper. - // In preview mode, allow drag selection from blocks since they - // are not contenteditable. - if ( - target.getAttribute( 'contenteditable' ) !== 'true' && - ! getSettings().isPreviewMode - ) { + // Only start multi selecting when the mouse leaves a field: + // one editable on its own, or the selected block, editable + // through the editing host. In preview mode, allow drag + // selection from blocks since they are not contenteditable. + const isField = + target.contentEditable === 'true' || + ( target.isContentEditable && + target.dataset.block === getSelectedBlockClientId() ); + if ( ! isField && ! getSettings().isPreviewMode ) { return; } diff --git a/packages/block-editor/src/components/writing-flow/use-selection-observer.js b/packages/block-editor/src/components/writing-flow/use-selection-observer.js index 01f521b62442c8..a3fc9165d8f1c3 100644 --- a/packages/block-editor/src/components/writing-flow/use-selection-observer.js +++ b/packages/block-editor/src/components/writing-flow/use-selection-observer.js @@ -184,20 +184,32 @@ export default function useSelectionObserver() { // While the wrapper is editable it must hold focus: a // nested editable element cannot retain it (the first // DOM mutation moves focus to the host, inconsistently - // across browsers). Don't steal focus from UI elements - // (e.g. buttons) or editables outside the block (e.g. - // the post title). The rich text instance owning the - // selection syncs it to the store itself. + // across browsers). Any focused element containing the + // caret hands over (Firefox focuses an ancestor block + // wrapper on a click in the inert field). UI elements + // and editables outside the block keep focus. The rich + // text instance owning the selection syncs it to the + // store itself. const { activeElement } = ownerDocument; if ( activeElement !== node && activeElement?.isContentEditable && node.contains( activeElement ) && - getBlockClientId( activeElement ) === - collapsedClientId + activeElement.contains( selection.anchorNode ) ) { node.focus(); + } else if ( + // A click on the inert field leaves the default + // target (iframe body or page body) active but + // unfocused: take focus for the host. + ( activeElement === node || + activeElement === ownerDocument.body ) && + ownerDocument.hasFocus() && + ! activeElement.matches( ':focus' ) + ) { + node.focus( { preventScroll: true } ); } + return; } diff --git a/packages/block-editor/src/utils/dom.ts b/packages/block-editor/src/utils/dom.ts index bf2e20e1360ce3..d4d2ef896fb10c 100644 --- a/packages/block-editor/src/utils/dom.ts +++ b/packages/block-editor/src/utils/dom.ts @@ -55,9 +55,21 @@ export function getSelectionEditableElement( anchorNode.nodeType === anchorNode.ELEMENT_NODE ? ( anchorNode as Element ) : anchorNode.parentElement; - const editable = element?.closest( '[contenteditable="true"]' ); + let editable = element?.closest( '[contenteditable="true"]' ); - if ( ! editable || editable === root || ! editable.contains( focusNode ) ) { + // While the root is the editing host, the selected block's field is + // editable through it and has no contenteditable attribute of its own: + // the block element is the editable element. + if ( editable === root ) { + editable = element?.closest( BLOCK_SELECTOR ); + } + + if ( + ! editable || + editable === root || + ! ( editable as HTMLElement ).isContentEditable || + ! editable.contains( focusNode ) + ) { return; } diff --git a/packages/rich-text/src/hook/event-listeners/input-and-selection.js b/packages/rich-text/src/hook/event-listeners/input-and-selection.js index 753890b195a5d2..ce9d20073b7108 100644 --- a/packages/rich-text/src/hook/event-listeners/input-and-selection.js +++ b/packages/rich-text/src/hook/event-listeners/input-and-selection.js @@ -104,8 +104,14 @@ export default ( props ) => ( element ) => { // Check if the implementor disabled editing. `contentEditable` does // disable input, but not text selection, so we must ignore selection - // changes. - if ( element.contentEditable !== 'true' ) { + // changes. The property reads "inherit" when the element has no + // attribute and is editable through the editing host, which counts as + // editable. This must stay a comparison against the known editable + // values: JSDOM does not implement the `contentEditable` getter (it + // returns undefined), and this handler must not run in that + // environment. + const { contentEditable } = element; + if ( contentEditable !== 'true' && contentEditable !== 'inherit' ) { return; } diff --git a/packages/rich-text/src/hook/event-listeners/prevent-focus-capture.js b/packages/rich-text/src/hook/event-listeners/prevent-focus-capture.js index fa69b2d5b28084..6664fe8f297c74 100644 --- a/packages/rich-text/src/hook/event-listeners/prevent-focus-capture.js +++ b/packages/rich-text/src/hook/event-listeners/prevent-focus-capture.js @@ -31,6 +31,12 @@ export function preventFocusCapture() { if ( ! event.target.closest( '[data-block]' ) ) { return; } + // Only an editing host of its own can capture focus. Under the + // block editor's editing host the element has no contenteditable + // attribute and is not a focus target. + if ( element.getAttribute( 'contenteditable' ) !== 'true' ) { + return; + } value = element.getAttribute( 'contenteditable' ); defaultView.getSelection().removeAllRanges(); element.setAttribute( 'contenteditable', 'false' ); diff --git a/packages/rich-text/src/owns-selection.js b/packages/rich-text/src/owns-selection.js index 9c0e168d0c4b48..2c317ab90b8658 100644 --- a/packages/rich-text/src/owns-selection.js +++ b/packages/rich-text/src/owns-selection.js @@ -20,12 +20,15 @@ export function ownsSelection( element ) { // computes the effective editable state and forces a style and layout tree // update. Since each editable element checks this on every keystroke, that // forced update would scale with the number of blocks in the post. The - // editing host and the editable both set the attribute explicitly, so the - // attribute is equivalent here. + // focused editing host sets the attribute explicitly, so the attribute is + // equivalent for it. The element itself may have no attribute and be + // editable through the host (the property reads "inherit" then); since + // the focused editing host must contain it, that counts as editable too. if ( ! activeElement || activeElement.contentEditable !== 'true' || - element.contentEditable !== 'true' || + ( element.contentEditable !== 'true' && + element.contentEditable !== 'inherit' ) || ! activeElement.contains( element ) ) { return false; diff --git a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js index 9c3f6ce5e9fa3a..680b38550ee366 100644 --- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js +++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js @@ -234,11 +234,33 @@ class KeyboardNavigableBlocks { } async expectLabelToHaveFocus( label ) { + if ( label.startsWith( 'Block: ' ) ) { + await this.expectBlockToOwnFocus( label ); + return; + } // Poll: the focused element and its label may settle asynchronously // (selection changes sync to the store on `selectionchange`). await expect.poll( this.editor.getFocusOwnerLabel ).toBe( label ); } + // The selected block is the labelled one, and it owns focus: it is + // focused itself, or the focused editing host contains it and the + // selection. + async expectBlockToOwnFocus( label ) { + const clientId = await this.page.evaluate( () => + window.wp.data + .select( 'core/block-editor' ) + .getSelectedBlockClientId() + ); + const block = this.editor.canvas.locator( + `[data-block="${ clientId }"]` + ); + await expect( block ).toHaveAttribute( 'aria-label', label ); + await expect + .poll( () => this.editor.ownsSelection( block ) ) + .toBe( true ); + } + async navigateThroughBlockToolbar() { await this.expectLabelToHaveFocus( 'Paragraph' ); diff --git a/test/e2e/specs/editor/various/splitting-merging.spec.js b/test/e2e/specs/editor/various/splitting-merging.spec.js index 013840977b7ecd..a1b5656b4668ee 100644 --- a/test/e2e/specs/editor/various/splitting-merging.spec.js +++ b/test/e2e/specs/editor/various/splitting-merging.spec.js @@ -868,14 +868,13 @@ test.describe( 'splitting and merging blocks (@firefox, @webkit)', () => { cancelled: window.__enterCancelled, } ) ); // Nothing happens on keydown: the beforeinput fires, is cancelled, - // and focus only moves while it is being handled. Focus moves into - // the new field, then to the editing host that engages once the - // block has a sibling. + // and focus only moves while it is being handled. Focus moves once, + // to the editing host that engages once the block has a sibling; the + // new field is not focusable on its own under the host. expect( events ).toEqual( [ 'keydown', 'beforeinput:insertParagraph', 'focusin', - 'focusin', ] ); expect( cancelled ).toBe( true ); diff --git a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js index 8fc24b7bcdcd8d..2ce6a25fcb9cac 100644 --- a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js +++ b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js @@ -162,6 +162,10 @@ class ToolbarRovingTabindexUtils { } async expectLabelToHaveFocus( label ) { + if ( label.startsWith( 'Block: ' ) ) { + await this.expectBlockToOwnFocus( label ); + return; + } let ariaLabel = await this.editor.getFocusOwnerLabel(); // If the labels don't match, try pressing Up Arrow to focus the block wrapper in non-content editable block. if ( ariaLabel !== label ) { @@ -171,6 +175,32 @@ class ToolbarRovingTabindexUtils { expect( ariaLabel ).toBe( label ); } + // The selected block is the labelled one, and it owns focus: it is + // focused itself, or the focused editing host contains it and the + // selection. + async expectBlockToOwnFocus( label ) { + const block = this.editor.canvas.locator( + `[data-block="${ await this.getSelectedBlockClientId() }"]` + ); + await expect( block ).toHaveAttribute( 'aria-label', label ); + if ( ! ( await this.editor.ownsSelection( block ) ) ) { + // Focus may be within a block without text (e.g. on an image + // placeholder button): ArrowUp moves it to the block wrapper. + await this.page.keyboard.press( 'ArrowUp' ); + } + await expect + .poll( () => this.editor.ownsSelection( block ) ) + .toBe( true ); + } + + async getSelectedBlockClientId() { + return this.page.evaluate( () => + window.wp.data + .select( 'core/block-editor' ) + .getSelectedBlockClientId() + ); + } + async wrapCurrentBlockWithGroup( currentBlockTitle ) { await this.page .getByRole( 'button', { name: currentBlockTitle, exact: true } ) diff --git a/test/e2e/specs/widgets/editing-widgets.spec.js b/test/e2e/specs/widgets/editing-widgets.spec.js index 80b8a365f149f5..44b70819027048 100644 --- a/test/e2e/specs/widgets/editing-widgets.spec.js +++ b/test/e2e/specs/widgets/editing-widgets.spec.js @@ -83,7 +83,11 @@ test.describe( 'Widgets screen', () => { { name: /^Empty block/ } ); - await addedParagraphBlockInFirstWidgetArea.focus(); + // Click rather than focus programmatically: once the widget area has + // a sibling, the block's editable is an inert part of the editing + // host and cannot hold focus; the click places the caret through the + // host like a real interaction. + await addedParagraphBlockInFirstWidgetArea.click(); await page.keyboard.type( 'First Paragraph' ); await widgetsScreen.getBlockInGlobalInserter( 'Paragraph' ); @@ -91,7 +95,7 @@ test.describe( 'Widgets screen', () => { // TODO: We can add a test for the insertion indicator here. await addParagraphBlock.click(); - await addedParagraphBlockInFirstWidgetArea.focus(); + await addedParagraphBlockInFirstWidgetArea.click(); await page.keyboard.type( 'Second Paragraph' ); const addShortCodeBlock = @@ -116,7 +120,7 @@ test.describe( 'Widgets screen', () => { secondWidgetArea.getByRole( 'document', { name: /^Empty block/, } ); - await addedParagraphBlockInSecondWidgetArea.focus(); + await addedParagraphBlockInSecondWidgetArea.click(); await page.keyboard.type( 'Third Paragraph' ); await expect.poll( widgetsScreen.getWidgetAreaBlocks ).toMatchObject( { @@ -570,10 +574,14 @@ test.describe( 'Widgets screen', () => { await widgetsScreen.saveWidgets(); // Delete the last block and save again. + // Click rather than focus programmatically: the block is still + // selected from its insertion, so its editable is an inert part of + // the editing host and cannot hold focus; the click places the caret + // through the host like a real interaction. await firstWidgetArea .getByRole( 'document', { name: 'Block: Paragraph' } ) .filter( { hasText: 'Second Paragraph' } ) - .focus(); + .click(); await pageUtils.pressKeys( 'access+z' ); await widgetsScreen.saveWidgets();