From 96ec6e553c331684fe5342ee7313b154ebc2912b Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 1 Sep 2026 21:36:01 -0700 Subject: [PATCH 01/35] Make the editor canvas a single tab stop The focus capture elements on either side of the canvas become the stop: tabbing towards the canvas lands on them instead of entering, Enter, Space, F2, the arrows, and Escape go in, Tab moves past the whole canvas, and Escape inside steps out onto the stop. A hint on the focused stop says how to get in. Linters were run by hand on every changed file; the pre-commit hook's lint task gets killed in this environment. Co-Authored-By: Claude Fable 5 --- packages/base-styles/_z-index.scss | 3 + .../src/components/writing-flow/style.scss | 45 +++++ .../components/writing-flow/use-tab-nav.jsx | 175 +++++++++++++----- packages/block-editor/src/style.scss | 1 + .../editor/various/canvas-tab-stop.spec.js | 144 ++++++++++++++ .../various/keyboard-navigable-blocks.spec.js | 7 + .../various/toolbar-roving-tabindex.spec.js | 3 + 7 files changed, 329 insertions(+), 49 deletions(-) create mode 100644 packages/block-editor/src/components/writing-flow/style.scss create mode 100644 test/e2e/specs/editor/various/canvas-tab-stop.spec.js diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index 451c20dc89cc38..5ddbeae2c30ea4 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -63,6 +63,9 @@ $z-layers: ( // Above the block list and the header. ".block-editor-block-popover": 31, + // The hint on the focused canvas stop, above the canvas. + ".block-editor-writing-flow__canvas-stop-hint": 32, + // Below the block toolbar. ".block-editor-grid-visualizer": 30, diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss new file mode 100644 index 00000000000000..f8120d83a2c679 --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -0,0 +1,45 @@ +@use "@wordpress/base-styles/variables" as *; +@use "@wordpress/base-styles/colors" as *; +@use "@wordpress/base-styles/z-index" as *; + +.block-editor-writing-flow__canvas-stop { + position: relative; + outline: none; +} + +.block-editor-writing-flow__canvas-stop-hint { + display: none; + position: absolute; + left: 50%; + transform: translateX(-50%); + z-index: z-index(".block-editor-writing-flow__canvas-stop-hint"); + padding: $grid-unit-10 $grid-unit-20; + border-radius: $radius-small; + background: $gray-900; + color: $white; + font-size: $default-font-size; + white-space: nowrap; + pointer-events: none; + + .block-editor-writing-flow__canvas-stop.is-before > & { + top: $grid-unit-20; + } + + .block-editor-writing-flow__canvas-stop.is-after > & { + bottom: $grid-unit-20; + } +} + +.block-editor-writing-flow__canvas-stop:focus > .block-editor-writing-flow__canvas-stop-hint { + display: block; +} + +/* The stop elements are zero-size, so the focus indication is drawn around + the canvas they stand for. */ +*:has(> .block-editor-writing-flow__canvas-stop:focus) > iframe[name="editor-canvas"], +*:has(> .block-editor-writing-flow__canvas-stop:focus) > .block-editor-writing-flow { + outline: + var(--wp-admin-border-width-focus) solid + var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); +} diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index ed22f1aa85f9c1..4804c8a506641d 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -1,7 +1,8 @@ import { focus, isFormElement } from '@wordpress/dom'; import { TAB } from '@wordpress/keycodes'; +import { __ } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; -import { useRefEffect, useMergeRefs } from '@wordpress/compose'; +import { useRefEffect, useMergeRefs, useInstanceId } from '@wordpress/compose'; import { useRef } from '@wordpress/element'; import { store as blockEditorStore } from '../../store'; import { isInSameBlock, isInsideRootBlock } from '../../utils/dom'; @@ -23,6 +24,7 @@ export default function useTabNav() { const containerRef = /** @type {typeof useRef} */ ( useRef )(); const focusCaptureBeforeRef = useRef(); const focusCaptureAfterRef = useRef(); + const hintId = useInstanceId( useTabNav, 'block-editor-canvas-stop-hint' ); const { hasMultiSelection, @@ -35,39 +37,38 @@ export default function useTabNav() { } = unlock( useSelect( blockEditorStore ) ); const { setLastFocus } = unlock( useDispatch( blockEditorStore ) ); - // Reference that holds the a flag for enabling or disabling - // capturing on the focus capture elements. - const noCaptureRef = useRef(); - - function onFocusCapture( event ) { - const canvasElement = - containerRef.current.ownerDocument === event.target.ownerDocument - ? containerRef.current - : containerRef.current.ownerDocument.defaultView.frameElement; - - // Do not capture incoming focus if set by us in WritingFlow. - if ( noCaptureRef.current ) { - noCaptureRef.current = null; - } else if ( hasMultiSelection() ) { + // The canvas is a single stop in the page's tab order: the two stop + // elements sit on either side of it, and going in is an explicit action + // on them. Focus returns to the place it last left the canvas from. + function enterCanvas( fromAfter ) { + if ( hasMultiSelection() ) { containerRef.current.focus(); - } else if ( getSelectedBlockClientId() ) { + return; + } + + if ( getSelectedBlockClientId() ) { if ( getLastFocus()?.current ) { getLastFocus().current.focus(); } else { - // Handles when the last focus has not been set yet, or has been cleared by new blocks being added via the inserter. + // Handles when the last focus has not been set yet, or has + // been cleared by new blocks being added via the inserter. containerRef.current .querySelector( `[data-block="${ getSelectedBlockClientId() }"]` ) .focus(); } + return; } - // In "compose" mode without a selected ID, we want to place focus on the section root when tabbing to the canvas. - else if ( isZoomOut() ) { + + // In "compose" mode without a selected ID, place focus on the + // section root when entering the canvas. + if ( isZoomOut() ) { const sectionRootClientId = getSectionRootClientId(); const sectionBlocks = getBlockOrder( sectionRootClientId ); - // If we have section within the section root, focus the first one. + // If we have a section within the section root, focus the first + // one. if ( sectionBlocks.length ) { containerRef.current .querySelector( `[data-block="${ sectionBlocks[ 0 ] }"]` ) @@ -79,40 +80,94 @@ export default function useTabNav() { .querySelector( `[data-block="${ sectionRootClientId }"]` ) .focus(); } else { - // If we don't have any section root, focus the canvas. - canvasElement.focus(); - } - } else { - const isBefore = - // eslint-disable-next-line no-bitwise - event.target.compareDocumentPosition( canvasElement ) & - event.target.DOCUMENT_POSITION_FOLLOWING; - const tabbables = focus.tabbable.find( containerRef.current ); - if ( tabbables.length ) { - const next = isBefore - ? tabbables[ 0 ] - : tabbables[ tabbables.length - 1 ]; - next.focus(); + containerRef.current.focus(); } + return; + } + + const tabbables = focus.tabbable.find( containerRef.current ); + if ( tabbables.length ) { + const next = fromAfter + ? tabbables[ tabbables.length - 1 ] + : tabbables[ 0 ]; + next.focus(); } } + function onStopKeyDown( event, isAfter ) { + if ( + event.defaultPrevented || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + + const { key } = event; + + if ( + ! event.shiftKey && + ( key === 'Enter' || + key === ' ' || + key === 'F2' || + key === 'Escape' || + key === 'ArrowDown' || + key === 'ArrowUp' ) + ) { + event.preventDefault(); + enterCanvas( key === 'ArrowUp' ? true : isAfter ); + return; + } + + // The whole canvas is one stop: Tab moves past it, so from the stop + // before it, skip over the contents and the stop after it, and the + // other way around backwards. + if ( key === 'Tab' && ! event.shiftKey && ! isAfter ) { + event.preventDefault(); + focus.tabbable.findNext( focusCaptureAfterRef.current )?.focus(); + return; + } + if ( key === 'Tab' && event.shiftKey && isAfter ) { + event.preventDefault(); + focus.tabbable + .findPrevious( focusCaptureBeforeRef.current ) + ?.focus(); + } + } + + const stopHint = ( id ) => ( +
+ { __( 'Press Enter to edit the document' ) } +
+ ); + const before = (
+ role="button" + aria-label={ __( 'Editor canvas' ) } + aria-describedby={ `${ hintId }-before` } + className="block-editor-writing-flow__canvas-stop is-before" + onKeyDown={ ( event ) => onStopKeyDown( event, false ) } + > + { stopHint( `${ hintId }-before` ) } +
); const after = (
+ role="button" + aria-label={ __( 'Editor canvas' ) } + aria-describedby={ `${ hintId }-after` } + className="block-editor-writing-flow__canvas-stop is-after" + onKeyDown={ ( event ) => onStopKeyDown( event, true ) } + > + { stopHint( `${ hintId }-after` ) } +
); const ref = useRefEffect( ( node ) => { @@ -121,12 +176,34 @@ export default function useTabNav() { return; } + // Escape steps out of the canvas onto the stop before it. + // Listeners delegated at a document level, among them the canvas + // listener undoing an automatic change, run after this handler + // for the same press. Waiting a microtask lets every one of them + // claim the key first: event dispatch is synchronous, so by then + // the event carries the final word. + if ( + event.key === 'Escape' && + ! event.ctrlKey && + ! event.metaKey && + ! event.altKey + ) { + queueMicrotask( () => { + if ( event.defaultPrevented ) { + return; + } + focusCaptureBeforeRef.current?.focus( { + preventScroll: true, + } ); + } ); + return; + } + // In Edit mode, Tab should focus the first tabbable element after // the content, which is normally the sidebar (with block controls) // and Shift+Tab should focus the first tabbable element before the // content, which is normally the block toolbar. - // Arrow keys can be used, and Tab and arrow keys can be used in - // Navigation mode (press Esc), to navigate through blocks. + // Arrow keys can be used to navigate through blocks. if ( event.keyCode !== TAB ) { return; } @@ -169,14 +246,14 @@ export default function useTabNav() { ) { return; } - const next = isShift ? focusCaptureBeforeRef : focusCaptureAfterRef; - - // Disable focus capturing on the focus capture element, so it - // doesn't refocus this block and so it allows default behaviour - // (moving focus to the next tabbable element). - noCaptureRef.current = true; - next.current.focus(); + // Tab out of the canvas: move focus past the stop on the side + // being tabbed towards, without stopping on it. + event.preventDefault(); + const outside = isShift + ? focus.tabbable.findPrevious( focusCaptureBeforeRef.current ) + : focus.tabbable.findNext( focusCaptureAfterRef.current ); + outside?.focus(); } function onFocusOut( event ) { diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index 4ed0aa9afc6701..24bf53bb6ad495 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -74,3 +74,4 @@ @use "./components/inserter/style.scss" as *; @use "./components/spacing-sizes-control/style.scss" as *; @use "./components/block-visibility/style.scss" as *; +@use "./components/writing-flow/style.scss" as *; diff --git a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js new file mode 100644 index 00000000000000..50054c3ebc11ee --- /dev/null +++ b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js @@ -0,0 +1,144 @@ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Canvas as a single tab stop', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + async function hasTextSelection( page ) { + return page.evaluate( () => { + const { getSelectionStart } = + window.wp.data.select( 'core/block-editor' ); + return getSelectionStart().attributeKey !== undefined; + } ); + } + + test( 'Escape steps out onto the canvas stop and Enter goes back in', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First' }, + } ); + await editor.canvas.getByText( 'First' ).click(); + await expect.poll( () => hasTextSelection( page ) ).toBe( true ); + + await page.keyboard.press( 'Escape' ); + const stopBefore = page + .getByRole( 'button', { name: 'Editor canvas' } ) + .first(); + await expect( stopBefore ).toBeFocused(); + await expect( + page.getByText( 'Press Enter to edit the document' ).first() + ).toBeVisible(); + + // The block selection is left as it is. + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/paragraph', attributes: { content: 'First' } }, + ] ); + + // Enter goes back to where focus left the canvas. + await page.keyboard.press( 'Enter' ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ) + ).toBeFocused(); + await expect.poll( () => hasTextSelection( page ) ).toBe( true ); + } ); + + test( 'Escape on the stop goes back in too', async ( { editor, page } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First' }, + } ); + await editor.canvas.getByText( 'First' ).click(); + + await page.keyboard.press( 'Escape' ); + await expect( + page.getByRole( 'button', { name: 'Editor canvas' } ).first() + ).toBeFocused(); + + await page.keyboard.press( 'Escape' ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ) + ).toBeFocused(); + } ); + + test( 'Tab moves past the canvas in both directions', async ( { + editor, + page, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First' }, + } ); + await editor.canvas.getByText( 'First' ).click(); + + // Out onto the stop, then Tab skips the whole canvas onwards. + await page.keyboard.press( 'Escape' ); + await page.keyboard.press( 'Tab' ); + await expect + .poll( () => + page.evaluate( () => + Boolean( + document.activeElement.closest( + '[aria-label="Editor settings"]' + ) + ) + ) + ) + .toBe( true ); + + // Shift+Tab from the sidebar lands on the stop after the canvas. + await page.keyboard.press( 'Shift+Tab' ); + await expect( + page.getByRole( 'button', { name: 'Editor canvas' } ).last() + ).toBeFocused(); + + // Shift+Tab again skips the whole canvas backwards, onto the block + // toolbar floating over it. + await page.keyboard.press( 'Shift+Tab' ); + await expect + .poll( () => + page.evaluate( () => + Boolean( + document.activeElement.closest( + '[aria-label="Block tools"]' + ) + ) + ) + ) + .toBe( true ); + } ); + + test( 'still undoes an automatic change first', async ( { + editor, + page, + } ) => { + await editor.canvas + .locator( 'role=document[name="Add default block"i]' ) + .click(); + await page.keyboard.type( '* ' ); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ { name: 'core/list' } ] ); + + await page.keyboard.press( 'Escape' ); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/paragraph', attributes: { content: '* ' } }, + ] ); + await expect.poll( () => hasTextSelection( page ) ).toBe( true ); + await expect( + page.getByRole( 'button', { name: 'Editor canvas' } ).first() + ).not.toBeFocused(); + } ); +} ); 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 ab01b5288f313f..627206191bd4cf 100644 --- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js +++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js @@ -30,6 +30,9 @@ test.describe( 'Order of block keyboard navigation', () => { await pageUtils.pressKeys( 'shift+Tab' ); await KeyboardNavigableBlocks.navigateThroughBlockToolbar(); await page.keyboard.press( 'Tab' ); + // The canvas is a single tab stop; Enter goes back to the block. + await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Editor canvas' ); + await page.keyboard.press( 'Enter' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Block: Paragraph' ); @@ -58,7 +61,11 @@ test.describe( 'Order of block keyboard navigation', () => { await page.keyboard.press( 'Tab' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Block' ); + // The canvas is a single tab stop; Enter goes back to the multi + // selection. await pageUtils.pressKeys( 'shift+Tab' ); + await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Editor canvas' ); + await page.keyboard.press( 'Enter' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Multiple selected blocks' ); 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..670f151f64f7ad 100644 --- a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js +++ b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js @@ -156,6 +156,9 @@ class ToolbarRovingTabindexUtils { await this.page.keyboard.press( 'ArrowRight' ); await this.expectLabelToHaveFocus( 'Move up' ); await this.pageUtils.pressKeys( 'Tab' ); + // The canvas is a single tab stop; Enter goes back to the block. + await this.expectLabelToHaveFocus( 'Editor canvas' ); + await this.page.keyboard.press( 'Enter' ); await this.expectLabelToHaveFocus( currentBlockLabel ); await this.pageUtils.pressKeys( 'shift+Tab' ); await this.expectLabelToHaveFocus( 'Move up' ); From 055345a3e7d36637d06015f44794dd07fa20b22d Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 1 Sep 2026 21:36:53 -0700 Subject: [PATCH 02/35] Add the changelog entry Co-Authored-By: Claude Fable 5 --- packages/block-editor/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 147219fbbdd4ea..7ab1e5797cd31f 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Experiments + +- The editor canvas is a single stop in the tab order: tabbing towards it lands on a labelled stop with a visible hint, Enter and a few forgiving alternatives go in, Escape steps out, and Tab moves past the whole canvas ([#82314](https://github.com/WordPress/gutenberg/pull/82314)). + ### New Features - `BackgroundImageControl`: Support setting the background image from a URL, through the URL field in the media replace popover ([#82230](https://github.com/WordPress/gutenberg/pull/82230)). From 50161affeae8d64ccefae42261beb18e980156c9 Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 1 Sep 2026 22:26:47 -0700 Subject: [PATCH 03/35] Show the hint as a real tooltip The design system tooltip is visual-only, so the hint stays wired up as a description for assistive technologies through a visually hidden element. The Escape press that steps out of the canvas is re-dispatched on the frame element for keyboard shortcuts; that copy would dismiss the tooltip it just revealed, so that one dismissal is cancelled. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- packages/base-styles/_z-index.scss | 3 - .../src/components/writing-flow/style.scss | 32 ------- .../components/writing-flow/use-tab-nav.jsx | 94 +++++++++++++------ .../editor/various/canvas-tab-stop.spec.js | 7 +- 4 files changed, 72 insertions(+), 64 deletions(-) diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index 5ddbeae2c30ea4..451c20dc89cc38 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -63,9 +63,6 @@ $z-layers: ( // Above the block list and the header. ".block-editor-block-popover": 31, - // The hint on the focused canvas stop, above the canvas. - ".block-editor-writing-flow__canvas-stop-hint": 32, - // Below the block toolbar. ".block-editor-grid-visualizer": 30, diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index f8120d83a2c679..56c061dae8ccfc 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -1,39 +1,7 @@ -@use "@wordpress/base-styles/variables" as *; -@use "@wordpress/base-styles/colors" as *; -@use "@wordpress/base-styles/z-index" as *; - .block-editor-writing-flow__canvas-stop { - position: relative; outline: none; } -.block-editor-writing-flow__canvas-stop-hint { - display: none; - position: absolute; - left: 50%; - transform: translateX(-50%); - z-index: z-index(".block-editor-writing-flow__canvas-stop-hint"); - padding: $grid-unit-10 $grid-unit-20; - border-radius: $radius-small; - background: $gray-900; - color: $white; - font-size: $default-font-size; - white-space: nowrap; - pointer-events: none; - - .block-editor-writing-flow__canvas-stop.is-before > & { - top: $grid-unit-20; - } - - .block-editor-writing-flow__canvas-stop.is-after > & { - bottom: $grid-unit-20; - } -} - -.block-editor-writing-flow__canvas-stop:focus > .block-editor-writing-flow__canvas-stop-hint { - display: block; -} - /* The stop elements are zero-size, so the focus indication is drawn around the canvas they stand for. */ *:has(> .block-editor-writing-flow__canvas-stop:focus) > iframe[name="editor-canvas"], diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 4804c8a506641d..42de20dc1b7b79 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -2,6 +2,7 @@ import { focus, isFormElement } from '@wordpress/dom'; import { TAB } from '@wordpress/keycodes'; import { __ } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; +import { Tooltip, VisuallyHidden } from '@wordpress/ui'; import { useRefEffect, useMergeRefs, useInstanceId } from '@wordpress/compose'; import { useRef } from '@wordpress/element'; import { store as blockEditorStore } from '../../store'; @@ -24,7 +25,10 @@ export default function useTabNav() { const containerRef = /** @type {typeof useRef} */ ( useRef )(); const focusCaptureBeforeRef = useRef(); const focusCaptureAfterRef = useRef(); - const hintId = useInstanceId( useTabNav, 'block-editor-canvas-stop-hint' ); + const hintId = useInstanceId( + useTabNav, + 'block-editor-writing-flow__canvas-stop-hint' + ); const { hasMultiSelection, @@ -136,38 +140,72 @@ export default function useTabNav() { } } - const stopHint = ( id ) => ( -
- { __( 'Press Enter to edit the document' ) } -
- ); + // The tooltip is visual-only, so the same hint is wired up as a + // description for assistive technologies. + const hint = __( 'Press Enter to edit the document' ); + + // The Escape press that steps focus out of the canvas is re-dispatched on + // the frame element for keyboard shortcuts. That copy arrives after focus + // has landed on the stop, so it would dismiss the tooltip it just + // revealed. A press on the stop itself is trusted and still dismisses. + function keepTooltipOnStepOut( open, details ) { + if ( + ! open && + details.reason === 'escape-key' && + details.event?.isTrusted === false + ) { + details.cancel(); + } + } const before = ( -
onStopKeyDown( event, false ) } - > - { stopHint( `${ hintId }-before` ) } -
+ + onStopKeyDown( event, false ) } + > + + { hint } + + + } + /> + }> + { hint } + + ); const after = ( -
onStopKeyDown( event, true ) } - > - { stopHint( `${ hintId }-after` ) } -
+ + onStopKeyDown( event, true ) } + > + + { hint } + + + } + /> + }> + { hint } + + ); const ref = useRefEffect( ( node ) => { diff --git a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js index 50054c3ebc11ee..334bc0fe209bc4 100644 --- a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js +++ b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js @@ -29,8 +29,13 @@ test.describe( 'Canvas as a single tab stop', () => { .getByRole( 'button', { name: 'Editor canvas' } ) .first(); await expect( stopBefore ).toBeFocused(); + // The hint shows as a tooltip on the focused stop. Both the popup and + // its positioner carry `data-open`; the popup is the inner one. await expect( - page.getByText( 'Press Enter to edit the document' ).first() + page + .locator( '[data-open]' ) + .filter( { hasText: 'Press Enter to edit the document' } ) + .last() ).toBeVisible(); // The block selection is left as it is. From 1d594978248c5ee6bfe2dac7e7dd518b5749a3db Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 1 Sep 2026 22:36:20 -0700 Subject: [PATCH 04/35] Show the hint in one place, whichever stop is focused Both tooltips anchor to the stop before the canvas, so the hint always appears at the top, instead of following the side you arrive from. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/use-tab-nav.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 42de20dc1b7b79..b1049f13a4e57a 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -202,7 +202,17 @@ export default function useTabNav() { } /> - }> + + } + > { hint } From a7ebcca3a42955d3d2e734cd12bb30e0dc2bc011 Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 1 Sep 2026 22:47:24 -0700 Subject: [PATCH 05/35] Drive the canvas focus ring with a class, not :has() Safari does not reliably recompute a :has(:focus) selector when focus moves, so the ring around the canvas could fail to appear there. The stops now toggle a class on their shared parent from focus and blur handlers, and the ring stays the standard focus outline. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/style.scss | 11 +++++------ .../components/writing-flow/use-tab-nav.jsx | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index 56c061dae8ccfc..2e81e500531b7b 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -3,11 +3,10 @@ } /* The stop elements are zero-size, so the focus indication is drawn around - the canvas they stand for. */ -*:has(> .block-editor-writing-flow__canvas-stop:focus) > iframe[name="editor-canvas"], -*:has(> .block-editor-writing-flow__canvas-stop:focus) > .block-editor-writing-flow { - outline: - var(--wp-admin-border-width-focus) solid - var(--wp-admin-theme-color); + the canvas they stand for. The class is toggled from the stops' focus and + blur handlers; Safari does not reliably recompute `:has(:focus)`. */ +.has-canvas-stop-focus > iframe[name="editor-canvas"], +.has-canvas-stop-focus > .block-editor-writing-flow { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); } diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index b1049f13a4e57a..c300f1353052e2 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -158,6 +158,21 @@ export default function useTabNav() { } } + // The focus ring is drawn around the canvas the stops stand for, through + // a class on their shared parent. Safari does not reliably recompute + // `:has(:focus)`, so the class is toggled here instead. + function onStopFocus( event ) { + event.currentTarget.parentElement?.classList.add( + 'has-canvas-stop-focus' + ); + } + + function onStopBlur( event ) { + event.currentTarget.parentElement?.classList.remove( + 'has-canvas-stop-focus' + ); + } + const before = ( onStopKeyDown( event, false ) } + onFocus={ onStopFocus } + onBlur={ onStopBlur } > { hint } @@ -195,6 +212,8 @@ export default function useTabNav() { aria-describedby={ `${ hintId }-after` } className="block-editor-writing-flow__canvas-stop" onKeyDown={ ( event ) => onStopKeyDown( event, true ) } + onFocus={ onStopFocus } + onBlur={ onStopBlur } > { hint } From c634ccc845aea6d8ca8cca67cf71348e8d8c01ea Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 07:53:52 -0700 Subject: [PATCH 06/35] Draw the canvas focus ring as an overlay Safari can paint an outline on a frame element underneath the frame's content, which hides the ring. An overlay on the shared parent covers the same box and paints above the frame everywhere. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/style.scss | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index 2e81e500531b7b..f59f697ce86969 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -3,10 +3,19 @@ } /* The stop elements are zero-size, so the focus indication is drawn around - the canvas they stand for. The class is toggled from the stops' focus and - blur handlers; Safari does not reliably recompute `:has(:focus)`. */ -.has-canvas-stop-focus > iframe[name="editor-canvas"], -.has-canvas-stop-focus > .block-editor-writing-flow { - outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); - outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + the canvas they stand for. It is an overlay rather than an outline on the + frame, which Safari can paint underneath the frame's content, and the class + is toggled from the stops' focus and blur handlers, because Safari does not + reliably recompute `:has(:focus)`. */ +.has-canvas-stop-focus { + position: relative; +} + +.has-canvas-stop-focus::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + border: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); } From 4c1c7cd099d9097e65ae8caf11c3ca97937a20ba Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 08:32:58 -0700 Subject: [PATCH 07/35] Lift the automatic-change undo out of rich text, drop the microtask The undo on Escape or Backspace right after an automatic change needs nothing from rich text, only the store, so it moves into the writing flow keydown ahead of the Escape that steps out of the canvas. With that, every handler with a stronger claim on Escape runs before the step-out, either deeper in the tree or in the capture phase like the autocomplete popover, and the step-out can act synchronously and claim the key itself. The tooltip keeps ignoring an Escape that another handler claimed, now by checking the claim instead of sniffing for the re-dispatched copy. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../rich-text/event-listeners/index.js | 2 - .../event-listeners/undo-automatic-change.js | 39 --------------- .../components/writing-flow/use-tab-nav.jsx | 49 +++++++++++-------- 3 files changed, 28 insertions(+), 62 deletions(-) delete mode 100644 packages/block-editor/src/components/rich-text/event-listeners/undo-automatic-change.js diff --git a/packages/block-editor/src/components/rich-text/event-listeners/index.js b/packages/block-editor/src/components/rich-text/event-listeners/index.js index cd53c94a7b92a9..c89c55a59e82c0 100644 --- a/packages/block-editor/src/components/rich-text/event-listeners/index.js +++ b/packages/block-editor/src/components/rich-text/event-listeners/index.js @@ -6,7 +6,6 @@ import insertReplacementText from './insert-replacement-text'; import removeBrowserShortcuts from './remove-browser-shortcuts'; import shortcuts from './shortcuts'; import inputEvents from './input-events'; -import undoAutomaticChange from './undo-automatic-change'; import pasteHandler from './paste-handler'; import _delete from './delete'; import enter from './enter'; @@ -19,7 +18,6 @@ const allEventListeners = [ removeBrowserShortcuts, shortcuts, inputEvents, - undoAutomaticChange, pasteHandler, _delete, enter, diff --git a/packages/block-editor/src/components/rich-text/event-listeners/undo-automatic-change.js b/packages/block-editor/src/components/rich-text/event-listeners/undo-automatic-change.js deleted file mode 100644 index 00fc5b79557eb2..00000000000000 --- a/packages/block-editor/src/components/rich-text/event-listeners/undo-automatic-change.js +++ /dev/null @@ -1,39 +0,0 @@ -import { BACKSPACE, ESCAPE } from '@wordpress/keycodes'; -import { privateApis as richTextPrivateApis } from '@wordpress/rich-text'; -import { store as blockEditorStore } from '../../../store'; -import { unlock } from '../../../lock-unlock'; - -const { subscribeOwnedListener } = unlock( richTextPrivateApis ); - -export default ( props ) => ( element ) => { - function onKeyDown( event ) { - const { keyCode } = event; - - if ( event.defaultPrevented ) { - return; - } - - if ( keyCode !== BACKSPACE && keyCode !== ESCAPE ) { - return; - } - - const { registry } = props.current; - const { didAutomaticChange, getSettings } = - registry.select( blockEditorStore ); - - const { __experimentalUndo } = getSettings(); - - if ( ! __experimentalUndo ) { - return; - } - - if ( ! didAutomaticChange() ) { - return; - } - - event.preventDefault(); - __experimentalUndo(); - } - - return subscribeOwnedListener( element, 'keydown', onKeyDown ); -}; diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index c300f1353052e2..96eddd8a1d6280 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -38,6 +38,8 @@ export default function useTabNav() { getLastFocus, getSectionRootClientId, isZoomOut, + didAutomaticChange, + getSettings, } = unlock( useSelect( blockEditorStore ) ); const { setLastFocus } = unlock( useDispatch( blockEditorStore ) ); @@ -144,15 +146,14 @@ export default function useTabNav() { // description for assistive technologies. const hint = __( 'Press Enter to edit the document' ); - // The Escape press that steps focus out of the canvas is re-dispatched on - // the frame element for keyboard shortcuts. That copy arrives after focus - // has landed on the stop, so it would dismiss the tooltip it just - // revealed. A press on the stop itself is trusted and still dismisses. - function keepTooltipOnStepOut( open, details ) { + // An Escape press that another handler already claimed, like the step + // out of the canvas that revealed this tooltip, should not also dismiss + // the tooltip. + function ignoreClaimedEscape( open, details ) { if ( ! open && details.reason === 'escape-key' && - details.event?.isTrusted === false + details.event?.defaultPrevented ) { details.cancel(); } @@ -174,7 +175,7 @@ export default function useTabNav() { } const before = ( - + + { - if ( event.defaultPrevented ) { - return; - } - focusCaptureBeforeRef.current?.focus( { - preventScroll: true, - } ); + event.preventDefault(); + focusCaptureBeforeRef.current?.focus( { + preventScroll: true, } ); return; } From 25e9ab9ce1fb8f32dea566e80be672cbfab7066b Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 08:42:50 -0700 Subject: [PATCH 08/35] Render the hint as a plain badge on the stop The interactive tooltip brought a state machine and an Escape dismissal protocol for what is a badge shown while the stop is focused. The stop now renders the badge itself, styled like a tooltip, shown with CSS on focus, and doubling as the description for assistive technologies. Both badges sit in the same place at the top of the canvas. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/style.scss | 30 +++++ .../components/writing-flow/use-tab-nav.jsx | 108 ++++++------------ .../editor/various/canvas-tab-stop.spec.js | 8 +- 3 files changed, 65 insertions(+), 81 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index f59f697ce86969..33b974eba674be 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -1,7 +1,37 @@ +@use "@wordpress/base-styles/colors" as *; +@use "@wordpress/base-styles/variables" as *; + .block-editor-writing-flow__canvas-stop { outline: none; } +/* Styled like a tooltip; shown while the stop is focused. Positioned against + the shared parent (made relative below), so both badges show in the same + place at the top of the canvas. */ +.block-editor-writing-flow__canvas-stop-hint { + display: none; + position: absolute; + top: $grid-unit-20; + left: 50%; + transform: translateX(-50%); + z-index: 2; + background: $black; + font-family: $default-font; + border-radius: var(--wpds-border-radius-md); + color: $gray-100; + text-align: center; + line-height: 1.4; + font-size: 12px; + padding: $grid-unit-05 $grid-unit-10; + box-shadow: $elevation-small; + white-space: nowrap; + pointer-events: none; +} + +.block-editor-writing-flow__canvas-stop:focus > .block-editor-writing-flow__canvas-stop-hint { + display: block; +} + /* The stop elements are zero-size, so the focus indication is drawn around the canvas they stand for. It is an overlay rather than an outline on the frame, which Safari can paint underneath the frame's content, and the class diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 96eddd8a1d6280..9633a73ee4f56b 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -2,7 +2,6 @@ import { focus, isFormElement } from '@wordpress/dom'; import { TAB } from '@wordpress/keycodes'; import { __ } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; -import { Tooltip, VisuallyHidden } from '@wordpress/ui'; import { useRefEffect, useMergeRefs, useInstanceId } from '@wordpress/compose'; import { useRef } from '@wordpress/element'; import { store as blockEditorStore } from '../../store'; @@ -142,22 +141,13 @@ export default function useTabNav() { } } - // The tooltip is visual-only, so the same hint is wired up as a - // description for assistive technologies. - const hint = __( 'Press Enter to edit the document' ); - - // An Escape press that another handler already claimed, like the step - // out of the canvas that revealed this tooltip, should not also dismiss - // the tooltip. - function ignoreClaimedEscape( open, details ) { - if ( - ! open && - details.reason === 'escape-key' && - details.event?.defaultPrevented - ) { - details.cancel(); - } - } + // The badge doubles as the stop's description for assistive + // technologies; hidden content still counts for `aria-describedby`. + const stopHint = ( id ) => ( +
+ { __( 'Press Enter to edit the document' ) } +
+ ); // The focus ring is drawn around the canvas the stops stand for, through // a class on their shared parent. Safari does not reliably recompute @@ -175,67 +165,35 @@ export default function useTabNav() { } const before = ( - - onStopKeyDown( event, false ) } - onFocus={ onStopFocus } - onBlur={ onStopBlur } - > - - { hint } - - - } - /> - }> - { hint } - - +
onStopKeyDown( event, false ) } + onFocus={ onStopFocus } + onBlur={ onStopBlur } + > + { stopHint( `${ hintId }-before` ) } +
); const after = ( - - onStopKeyDown( event, true ) } - onFocus={ onStopFocus } - onBlur={ onStopBlur } - > - - { hint } - - - } - /> - - } - > - { hint } - - +
onStopKeyDown( event, true ) } + onFocus={ onStopFocus } + onBlur={ onStopBlur } + > + { stopHint( `${ hintId }-after` ) } +
); const ref = useRefEffect( ( node ) => { diff --git a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js index 334bc0fe209bc4..0424079e9a3e6a 100644 --- a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js +++ b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js @@ -29,13 +29,9 @@ test.describe( 'Canvas as a single tab stop', () => { .getByRole( 'button', { name: 'Editor canvas' } ) .first(); await expect( stopBefore ).toBeFocused(); - // The hint shows as a tooltip on the focused stop. Both the popup and - // its positioner carry `data-open`; the popup is the inner one. + // The focused stop shows its hint badge. await expect( - page - .locator( '[data-open]' ) - .filter( { hasText: 'Press Enter to edit the document' } ) - .last() + page.getByText( 'Press Enter to edit the document' ).first() ).toBeVisible(); // The block selection is left as it is. From 8344055089dd2b525eaf2dc0f90f21c73a6d3150 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 08:50:28 -0700 Subject: [PATCH 09/35] Drop the changelog entry until this is headed to merge Co-Authored-By: Claude Fable 5 --- packages/block-editor/CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 7ab1e5797cd31f..147219fbbdd4ea 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -2,10 +2,6 @@ ## Unreleased -### Experiments - -- The editor canvas is a single stop in the tab order: tabbing towards it lands on a labelled stop with a visible hint, Enter and a few forgiving alternatives go in, Escape steps out, and Tab moves past the whole canvas ([#82314](https://github.com/WordPress/gutenberg/pull/82314)). - ### New Features - `BackgroundImageControl`: Support setting the background image from a URL, through the URL field in the media replace popover ([#82230](https://github.com/WordPress/gutenberg/pull/82230)). From c05de3751ef65711b01ce0455133c82b462585fe Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 09:22:11 -0700 Subject: [PATCH 10/35] One wrapper, one stop A wrapper div contains the canvas and, as its last element, the single stop. Tabbing backwards lands on the stop by document order; tabbing forwards lands on the wrapper, which forwards focus to the stop. The wrapper anchors the focus ring and the hint, both drawn by the focused stop with plain :focus rules, so the class toggling goes away, and so does the second stop. Both the iframed and the plain canvas render the same wrapper; preview canvases render it inert, without the stop. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/iframe/index.jsx | 139 +++++++++--------- .../src/components/writing-flow/index.jsx | 15 +- .../src/components/writing-flow/style.scss | 44 +++--- .../components/writing-flow/use-tab-nav.jsx | 133 +++++++---------- 4 files changed, 157 insertions(+), 174 deletions(-) diff --git a/packages/block-editor/src/components/iframe/index.jsx b/packages/block-editor/src/components/iframe/index.jsx index 8d5da7d87dec47..4f9f2e421b31d9 100644 --- a/packages/block-editor/src/components/iframe/index.jsx +++ b/packages/block-editor/src/components/iframe/index.jsx @@ -151,7 +151,7 @@ function Iframe( { /** @type {[Document, React.Dispatch]} */ const [ iframeDocument, setIframeDocument ] = useState(); const [ bodyClasses, setBodyClasses ] = useState( [] ); - const [ before, writingFlowRef, after ] = useWritingFlow(); + const [ wrapperProps, writingFlowRef, canvasStop ] = useWritingFlow(); const setRef = useRefEffect( ( node ) => { let iFrameDocument; @@ -293,75 +293,71 @@ function Iframe( { const src = getIframeSrc( resolvedAssets ); - // Make sure to not render the before and after focusable div elements in view - // mode. They're only needed to capture focus in edit mode. + // Make sure to not render the canvas wrapper and stop in view mode. + // They're only needed to catch focus in edit mode. const shouldRenderFocusCaptureElements = tabIndex >= 0 && ! isPreviewMode; const iframe = ( - <> - { shouldRenderFocusCaptureElements && before } - { /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */ } - - { shouldRenderFocusCaptureElements && after } - + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + ); return ( @@ -376,7 +372,14 @@ function Iframe( { isZoomedOut && `${ scaleContainerWidth }px`, } } > - { iframe } +
+ { iframe } + { shouldRenderFocusCaptureElements && canvasStop } +
); diff --git a/packages/block-editor/src/components/writing-flow/index.jsx b/packages/block-editor/src/components/writing-flow/index.jsx index 4e8e16c7c7ca4b..d02e8b5909410d 100644 --- a/packages/block-editor/src/components/writing-flow/index.jsx +++ b/packages/block-editor/src/components/writing-flow/index.jsx @@ -19,14 +19,14 @@ import useClipboardHandler from './use-clipboard-handler'; import { store as blockEditorStore } from '../../store'; export function useWritingFlow() { - const [ before, ref, after ] = useTabNav(); + const [ wrapperProps, ref, canvasStop ] = useTabNav(); const hasMultiSelection = useSelect( ( select ) => select( blockEditorStore ).hasMultiSelection(), [] ); return [ - before, + wrapperProps, useMergeRefs( [ ref, useEditableRootEventHandlers(), @@ -75,15 +75,14 @@ export function useWritingFlow() { [ hasMultiSelection ] ), ] ), - after, + canvasStop, ]; } function WritingFlow( { children, ...props }, forwardedRef ) { - const [ before, ref, after ] = useWritingFlow(); + const [ wrapperProps, ref, canvasStop ] = useWritingFlow(); return ( - <> - { before } +
{ children }
- { after } - + { canvasStop } +
); } diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index 33b974eba674be..4301a9c0baff97 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -1,13 +1,33 @@ @use "@wordpress/base-styles/colors" as *; @use "@wordpress/base-styles/variables" as *; +/* The wrapper contains the canvas and the stop after it, and anchors the + focus ring and hint drawn by the focused stop. In the iframed editor it + sits between the scale container and the frame, so it passes the full + height down. */ +.block-editor-writing-flow__canvas-wrapper { + position: relative; + height: 100%; +} + .block-editor-writing-flow__canvas-stop { outline: none; } -/* Styled like a tooltip; shown while the stop is focused. Positioned against - the shared parent (made relative below), so both badges show in the same - place at the top of the canvas. */ +/* The stop element is zero-size, so its focus indication is drawn around + the wrapper box it stands for: an overlay rather than an outline on the + frame, which Safari can paint underneath the frame's content. */ +.block-editor-writing-flow__canvas-stop:focus::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + border: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); +} + +/* Styled like a tooltip; shown while the stop is focused, at the top of the + wrapper. Doubles as the stop's description for assistive technologies. */ .block-editor-writing-flow__canvas-stop-hint { display: none; position: absolute; @@ -31,21 +51,3 @@ .block-editor-writing-flow__canvas-stop:focus > .block-editor-writing-flow__canvas-stop-hint { display: block; } - -/* The stop elements are zero-size, so the focus indication is drawn around - the canvas they stand for. It is an overlay rather than an outline on the - frame, which Safari can paint underneath the frame's content, and the class - is toggled from the stops' focus and blur handlers, because Safari does not - reliably recompute `:has(:focus)`. */ -.has-canvas-stop-focus { - position: relative; -} - -.has-canvas-stop-focus::after { - content: ""; - position: absolute; - inset: 0; - z-index: 1; - pointer-events: none; - border: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); -} diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 9633a73ee4f56b..276c8348297d2a 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -22,8 +22,7 @@ const PREVENT_SCROLL_ON_FOCUS = { export default function useTabNav() { const containerRef = /** @type {typeof useRef} */ ( useRef )(); - const focusCaptureBeforeRef = useRef(); - const focusCaptureAfterRef = useRef(); + const canvasStopRef = useRef(); const hintId = useInstanceId( useTabNav, 'block-editor-writing-flow__canvas-stop-hint' @@ -99,7 +98,7 @@ export default function useTabNav() { } } - function onStopKeyDown( event, isAfter ) { + function onStopKeyDown( event ) { if ( event.defaultPrevented || event.ctrlKey || @@ -121,78 +120,59 @@ export default function useTabNav() { key === 'ArrowUp' ) ) { event.preventDefault(); - enterCanvas( key === 'ArrowUp' ? true : isAfter ); + enterCanvas( key === 'ArrowUp' ); return; } - // The whole canvas is one stop: Tab moves past it, so from the stop - // before it, skip over the contents and the stop after it, and the - // other way around backwards. - if ( key === 'Tab' && ! event.shiftKey && ! isAfter ) { - event.preventDefault(); - focus.tabbable.findNext( focusCaptureAfterRef.current )?.focus(); - return; - } - if ( key === 'Tab' && event.shiftKey && isAfter ) { + // Tab forwards leaves the wrapper naturally: the stop is its last + // element. Backwards, sequential order would walk into the content, + // so move focus to the last tabbable before the wrapper instead. + if ( key === 'Tab' && event.shiftKey ) { event.preventDefault(); focus.tabbable - .findPrevious( focusCaptureBeforeRef.current ) + .findPrevious( event.currentTarget.parentElement ) ?.focus(); } } - // The badge doubles as the stop's description for assistive - // technologies; hidden content still counts for `aria-describedby`. - const stopHint = ( id ) => ( -
- { __( 'Press Enter to edit the document' ) } -
- ); - - // The focus ring is drawn around the canvas the stops stand for, through - // a class on their shared parent. Safari does not reliably recompute - // `:has(:focus)`, so the class is toggled here instead. - function onStopFocus( event ) { - event.currentTarget.parentElement?.classList.add( - 'has-canvas-stop-focus' - ); + // The wrapper is not a stop of its own: focus arriving from outside, + // which sequential navigation only delivers travelling forwards, is + // forwarded to the stop after the content. Backwards, the stop is the + // wrapper's last tabbable and catches focus by itself. + function onWrapperFocus( event ) { + if ( + event.target === event.currentTarget && + ! event.currentTarget.contains( event.relatedTarget ) + ) { + canvasStopRef.current?.focus(); + } } - function onStopBlur( event ) { - event.currentTarget.parentElement?.classList.remove( - 'has-canvas-stop-focus' - ); - } + const wrapperProps = { + className: 'block-editor-writing-flow__canvas-wrapper', + tabIndex: '0', + onFocus: onWrapperFocus, + }; - const before = ( + const stop = (
onStopKeyDown( event, false ) } - onFocus={ onStopFocus } - onBlur={ onStopBlur } + onKeyDown={ onStopKeyDown } > - { stopHint( `${ hintId }-before` ) } -
- ); - - const after = ( -
onStopKeyDown( event, true ) } - onFocus={ onStopFocus } - onBlur={ onStopBlur } - > - { stopHint( `${ hintId }-after` ) } + { /* The badge doubles as the stop's description for assistive + technologies; hidden content still counts for + `aria-describedby`. */ } +
+ { __( 'Press Enter to edit the document' ) } +
); @@ -214,20 +194,20 @@ export default function useTabNav() { } } - // Escape steps out of the canvas onto the stop before it. Every - // handler with a stronger claim on the key runs earlier, either - // deeper in the tree or in the capture phase, so an unclaimed - // key here means the key is free. + // Escape steps out of the canvas onto its stop. Every handler + // with a stronger claim on the key runs earlier, either deeper + // in the tree or in the capture phase, so an unclaimed key here + // means the key is free. if ( event.key === 'Escape' && ! event.ctrlKey && ! event.metaKey && ! event.altKey ) { - event.preventDefault(); - focusCaptureBeforeRef.current?.focus( { - preventScroll: true, - } ); + if ( canvasStopRef.current ) { + event.preventDefault(); + canvasStopRef.current.focus( { preventScroll: true } ); + } return; } @@ -240,13 +220,10 @@ export default function useTabNav() { return; } - if ( - // Bails in case the focus capture elements aren’t present. They - // may be omitted to avoid silent tab stops in preview mode. - // See: https://github.com/WordPress/gutenberg/pull/59317 - ! focusCaptureAfterRef.current || - ! focusCaptureBeforeRef.current - ) { + // Bails in case the stop isn’t present. It may be omitted to + // avoid a silent tab stop in preview mode. + // See: https://github.com/WordPress/gutenberg/pull/59317 + if ( ! canvasStopRef.current ) { return; } @@ -279,12 +256,14 @@ export default function useTabNav() { return; } - // Tab out of the canvas: move focus past the stop on the side - // being tabbed towards, without stopping on it. + // Tab out of the canvas: forwards past the stop, backwards past + // the wrapper, without stopping on either. event.preventDefault(); const outside = isShift - ? focus.tabbable.findPrevious( focusCaptureBeforeRef.current ) - : focus.tabbable.findNext( focusCaptureAfterRef.current ); + ? focus.tabbable.findPrevious( + canvasStopRef.current.parentElement + ) + : focus.tabbable.findNext( canvasStopRef.current ); outside?.focus(); } @@ -321,5 +300,5 @@ export default function useTabNav() { const mergedRefs = useMergeRefs( [ containerRef, ref ] ); - return [ before, mergedRefs, after ]; + return [ wrapperProps, mergedRefs, stop ]; } From 2a5f2c36429adb94f3fd2a70bfe22e21bbb79050 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 09:32:55 -0700 Subject: [PATCH 11/35] Focus the canvas window before the element in it Entering starts from the stop in the parent document, and Firefox does not move window focus for a cross-document element.focus(): the caret looked restored but every key stayed in the parent. Present since the first commit; caught by probing all three engines. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/use-tab-nav.jsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 276c8348297d2a..0ac35edd27755d 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -41,10 +41,16 @@ export default function useTabNav() { } = unlock( useSelect( blockEditorStore ) ); const { setLastFocus } = unlock( useDispatch( blockEditorStore ) ); - // The canvas is a single stop in the page's tab order: the two stop - // elements sit on either side of it, and going in is an explicit action - // on them. Focus returns to the place it last left the canvas from. + // The canvas is a single stop in the page's tab order, and going in is + // an explicit action on it. Focus returns to the place it last left the + // canvas from. function enterCanvas( fromAfter ) { + // Focus the canvas window before any element in it: entering starts + // from the stop in the parent document, and Firefox does not move + // window focus for a cross-document `element.focus()`, leaving the + // caret restored but every key stranded in the parent. + containerRef.current.ownerDocument.defaultView.focus(); + if ( hasMultiSelection() ) { containerRef.current.focus(); return; From 97fbbad10b7d1f5464a46bdad905c3c9b9a00edd Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 09:32:55 -0700 Subject: [PATCH 12/35] Keep the frame's document out of the page's tab order With tabindex -1 on the frame, the stop is the only thing sequential navigation finds inside the wrapper, from both directions, so the wrapper stops catching focus and the iframed canvas needs no focus forwarding at all. Verified in Chromium, Firefox, and WebKit. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/iframe/index.jsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/block-editor/src/components/iframe/index.jsx b/packages/block-editor/src/components/iframe/index.jsx index 4f9f2e421b31d9..96965ce2cbe15d 100644 --- a/packages/block-editor/src/components/iframe/index.jsx +++ b/packages/block-editor/src/components/iframe/index.jsx @@ -293,9 +293,9 @@ function Iframe( { const src = getIframeSrc( resolvedAssets ); - // Make sure to not render the canvas wrapper and stop in view mode. - // They're only needed to catch focus in edit mode. - const shouldRenderFocusCaptureElements = tabIndex >= 0 && ! isPreviewMode; + // The stop is only rendered in edit mode; view mode should not grow a + // silent tab stop. + const isInteractiveCanvas = tabIndex >= 0 && ! isPreviewMode; const iframe = ( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions @@ -307,7 +307,12 @@ function Iframe( { border: 0, } } ref={ useMergeRefs( [ ref, setRef ] ) } - tabIndex={ tabIndex } + // A frame with a negative tabindex keeps its whole document out + // of the page's sequential focus order, which is what makes the + // canvas a single stop: the stop after the frame is the only + // thing Tab finds, in either direction. Focus still enters the + // canvas by click, script, and Enter on the stop. + tabIndex={ isInteractiveCanvas ? -1 : tabIndex } src={ src } title={ title } onKeyDown={ ( event ) => { @@ -372,13 +377,12 @@ function Iframe( { isZoomedOut && `${ scaleContainerWidth }px`, } } > -
+ { /* With the frame's document out of sequential order, the + wrapper needs no focus catching here: the stop is the + only tabbable inside it, from both directions. */ } +
{ iframe } - { shouldRenderFocusCaptureElements && canvasStop } + { isInteractiveCanvas && canvasStop }
From b22fe939df90124bd422df36be186d77b30525e9 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 10:11:15 -0700 Subject: [PATCH 13/35] Restore the before and after elements; the before one is the stop The frame keeps its tabindex, and both canvases use the same logic again: the element before the canvas is the single labelled stop, and the element after it forwards focus to the stop, so the stop is reached from either direction. This puts WritingFlow and Iframe back to their markup on trunk. The focused stop draws the ring and hint itself: over the frame as an overlay stretched on the scale container, since Safari paints an outline on a frame element underneath the frame's content, and on the plain canvas as an outline on the sibling. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/iframe/index.jsx | 145 +++++++++--------- .../src/components/iframe/style.scss | 2 + .../src/components/writing-flow/index.jsx | 15 +- .../src/components/writing-flow/style.scss | 33 ++-- .../components/writing-flow/use-tab-nav.jsx | 76 +++++---- 5 files changed, 135 insertions(+), 136 deletions(-) diff --git a/packages/block-editor/src/components/iframe/index.jsx b/packages/block-editor/src/components/iframe/index.jsx index 96965ce2cbe15d..8d5da7d87dec47 100644 --- a/packages/block-editor/src/components/iframe/index.jsx +++ b/packages/block-editor/src/components/iframe/index.jsx @@ -151,7 +151,7 @@ function Iframe( { /** @type {[Document, React.Dispatch]} */ const [ iframeDocument, setIframeDocument ] = useState(); const [ bodyClasses, setBodyClasses ] = useState( [] ); - const [ wrapperProps, writingFlowRef, canvasStop ] = useWritingFlow(); + const [ before, writingFlowRef, after ] = useWritingFlow(); const setRef = useRefEffect( ( node ) => { let iFrameDocument; @@ -293,76 +293,75 @@ function Iframe( { const src = getIframeSrc( resolvedAssets ); - // The stop is only rendered in edit mode; view mode should not grow a - // silent tab stop. - const isInteractiveCanvas = tabIndex >= 0 && ! isPreviewMode; + // Make sure to not render the before and after focusable div elements in view + // mode. They're only needed to capture focus in edit mode. + const shouldRenderFocusCaptureElements = tabIndex >= 0 && ! isPreviewMode; const iframe = ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions - + <> + { shouldRenderFocusCaptureElements && before } + { /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */ } + + { shouldRenderFocusCaptureElements && after } + ); return ( @@ -377,13 +376,7 @@ function Iframe( { isZoomedOut && `${ scaleContainerWidth }px`, } } > - { /* With the frame's document out of sequential order, the - wrapper needs no focus catching here: the stop is the - only tabbable inside it, from both directions. */ } -
- { iframe } - { isInteractiveCanvas && canvasStop } -
+ { iframe } ); diff --git a/packages/block-editor/src/components/iframe/style.scss b/packages/block-editor/src/components/iframe/style.scss index 0daa285a7b1f4b..80f0d81e06eba5 100644 --- a/packages/block-editor/src/components/iframe/style.scss +++ b/packages/block-editor/src/components/iframe/style.scss @@ -12,6 +12,8 @@ .block-editor-iframe__scale-container { height: 100%; + /* The anchor for the focus ring and hint of the canvas stop inside. */ + position: relative; } .block-editor-iframe__scale-container.is-zoomed-out { diff --git a/packages/block-editor/src/components/writing-flow/index.jsx b/packages/block-editor/src/components/writing-flow/index.jsx index d02e8b5909410d..4e8e16c7c7ca4b 100644 --- a/packages/block-editor/src/components/writing-flow/index.jsx +++ b/packages/block-editor/src/components/writing-flow/index.jsx @@ -19,14 +19,14 @@ import useClipboardHandler from './use-clipboard-handler'; import { store as blockEditorStore } from '../../store'; export function useWritingFlow() { - const [ wrapperProps, ref, canvasStop ] = useTabNav(); + const [ before, ref, after ] = useTabNav(); const hasMultiSelection = useSelect( ( select ) => select( blockEditorStore ).hasMultiSelection(), [] ); return [ - wrapperProps, + before, useMergeRefs( [ ref, useEditableRootEventHandlers(), @@ -75,14 +75,15 @@ export function useWritingFlow() { [ hasMultiSelection ] ), ] ), - canvasStop, + after, ]; } function WritingFlow( { children, ...props }, forwardedRef ) { - const [ wrapperProps, ref, canvasStop ] = useWritingFlow(); + const [ before, ref, after ] = useWritingFlow(); return ( -
+ <> + { before }
{ children }
- { canvasStop } -
+ { after } + ); } diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index 4301a9c0baff97..b2bfbabaf9d82f 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -1,23 +1,27 @@ @use "@wordpress/base-styles/colors" as *; @use "@wordpress/base-styles/variables" as *; -/* The wrapper contains the canvas and the stop after it, and anchors the - focus ring and hint drawn by the focused stop. In the iframed editor it - sits between the scale container and the frame, so it passes the full - height down. */ -.block-editor-writing-flow__canvas-wrapper { +.block-editor-writing-flow__canvas-stop { + /* The anchor for the focus ring and the hint. In the iframed editor the + positioned scale container is the anchor instead, so both stretch over + the canvas box rather than this zero-size element. */ position: relative; - height: 100%; + outline: none; } -.block-editor-writing-flow__canvas-stop { +.block-editor-iframe__scale-container > .block-editor-writing-flow__canvas-stop { + position: static; +} + +.block-editor-writing-flow__canvas-stop-redirect { outline: none; } -/* The stop element is zero-size, so its focus indication is drawn around - the wrapper box it stands for: an overlay rather than an outline on the - frame, which Safari can paint underneath the frame's content. */ -.block-editor-writing-flow__canvas-stop:focus::after { +/* The stop element is zero-size, so its focus indication is drawn around the + canvas it stands for, right after it. For the frame it is an overlay + stretched over the positioned scale container: Safari paints an outline on + a frame element underneath the frame's content. */ +.block-editor-iframe__scale-container > .block-editor-writing-flow__canvas-stop:focus::after { content: ""; position: absolute; inset: 0; @@ -26,8 +30,13 @@ border: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); } +.block-editor-writing-flow__canvas-stop:focus + .block-editor-writing-flow { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); +} + /* Styled like a tooltip; shown while the stop is focused, at the top of the - wrapper. Doubles as the stop's description for assistive technologies. */ + canvas. Doubles as the stop's description for assistive technologies. */ .block-editor-writing-flow__canvas-stop-hint { display: none; position: absolute; diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 0ac35edd27755d..e800ecbff71217 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -22,7 +22,8 @@ const PREVENT_SCROLL_ON_FOCUS = { export default function useTabNav() { const containerRef = /** @type {typeof useRef} */ ( useRef )(); - const canvasStopRef = useRef(); + const focusCaptureBeforeRef = useRef(); + const focusCaptureAfterRef = useRef(); const hintId = useInstanceId( useTabNav, 'block-editor-writing-flow__canvas-stop-hint' @@ -130,39 +131,18 @@ export default function useTabNav() { return; } - // Tab forwards leaves the wrapper naturally: the stop is its last - // element. Backwards, sequential order would walk into the content, - // so move focus to the last tabbable before the wrapper instead. - if ( key === 'Tab' && event.shiftKey ) { + // Backwards, Tab leaves the canvas naturally: the stop is the first + // element before it. Forwards, sequential order would walk into the + // content, so move focus to the first tabbable past the canvas. + if ( key === 'Tab' && ! event.shiftKey ) { event.preventDefault(); - focus.tabbable - .findPrevious( event.currentTarget.parentElement ) - ?.focus(); + focus.tabbable.findNext( focusCaptureAfterRef.current )?.focus(); } } - // The wrapper is not a stop of its own: focus arriving from outside, - // which sequential navigation only delivers travelling forwards, is - // forwarded to the stop after the content. Backwards, the stop is the - // wrapper's last tabbable and catches focus by itself. - function onWrapperFocus( event ) { - if ( - event.target === event.currentTarget && - ! event.currentTarget.contains( event.relatedTarget ) - ) { - canvasStopRef.current?.focus(); - } - } - - const wrapperProps = { - className: 'block-editor-writing-flow__canvas-wrapper', - tabIndex: '0', - onFocus: onWrapperFocus, - }; - - const stop = ( + const before = (
); + // Focus arriving behind the canvas is forwarded to the stop before it, + // so the canvas has one stop, reached from either direction. + const after = ( +
focusCaptureBeforeRef.current?.focus() } + /> + ); + const ref = useRefEffect( ( node ) => { function onKeyDown( event ) { if ( event.defaultPrevented ) { @@ -210,9 +201,11 @@ export default function useTabNav() { ! event.metaKey && ! event.altKey ) { - if ( canvasStopRef.current ) { + if ( focusCaptureBeforeRef.current ) { event.preventDefault(); - canvasStopRef.current.focus( { preventScroll: true } ); + focusCaptureBeforeRef.current.focus( { + preventScroll: true, + } ); } return; } @@ -226,10 +219,13 @@ export default function useTabNav() { return; } - // Bails in case the stop isn’t present. It may be omitted to - // avoid a silent tab stop in preview mode. + // Bails in case the focus capture elements aren’t present. They + // may be omitted to avoid silent tab stops in preview mode. // See: https://github.com/WordPress/gutenberg/pull/59317 - if ( ! canvasStopRef.current ) { + if ( + ! focusCaptureAfterRef.current || + ! focusCaptureBeforeRef.current + ) { return; } @@ -262,14 +258,12 @@ export default function useTabNav() { return; } - // Tab out of the canvas: forwards past the stop, backwards past - // the wrapper, without stopping on either. + // Tab out of the canvas: move focus past the element on the side + // being tabbed towards, without stopping on it. event.preventDefault(); const outside = isShift - ? focus.tabbable.findPrevious( - canvasStopRef.current.parentElement - ) - : focus.tabbable.findNext( canvasStopRef.current ); + ? focus.tabbable.findPrevious( focusCaptureBeforeRef.current ) + : focus.tabbable.findNext( focusCaptureAfterRef.current ); outside?.focus(); } @@ -306,5 +300,5 @@ export default function useTabNav() { const mergedRefs = useMergeRefs( [ containerRef, ref ] ); - return [ wrapperProps, mergedRefs, stop ]; + return [ before, mergedRefs, after ]; } From abcc1d126a55312db6bb770ba1c8fb8f24fc37d3 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 10:30:57 -0700 Subject: [PATCH 14/35] Move the automatic change undo into its own writing flow file Also restore the comments and the tab scroll prevention to their trunk lines, so the diff only shows real changes. The scroll prevention is still needed when the canvas is not iframed, and the redirect forwards focus without scrolling for the same reason. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/index.jsx | 2 + .../components/writing-flow/use-tab-nav.jsx | 79 +++++++++++++------ .../writing-flow/use-undo-automatic-change.js | 45 +++++++++++ 3 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js diff --git a/packages/block-editor/src/components/writing-flow/index.jsx b/packages/block-editor/src/components/writing-flow/index.jsx index 4e8e16c7c7ca4b..5e29811cd3f33f 100644 --- a/packages/block-editor/src/components/writing-flow/index.jsx +++ b/packages/block-editor/src/components/writing-flow/index.jsx @@ -7,6 +7,7 @@ import useEditableRoot from './use-editable-root'; import useHomeEnd from './use-home-end'; import useMultiSelection from './use-multi-selection'; import useTabNav from './use-tab-nav'; +import useUndoAutomaticChange from './use-undo-automatic-change'; import useArrowNav from './use-arrow-nav'; import { usePreviewModeNav } from './use-preview-mode-nav'; import useSelectAll from './use-select-all'; @@ -29,6 +30,7 @@ export function useWritingFlow() { before, useMergeRefs( [ ref, + useUndoAutomaticChange(), useEditableRootEventHandlers(), useClipboardHandler(), useInput(), diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index e800ecbff71217..25ecae3dc668b7 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -37,8 +37,6 @@ export default function useTabNav() { getLastFocus, getSectionRootClientId, isZoomOut, - didAutomaticChange, - getSettings, } = unlock( useSelect( blockEditorStore ) ); const { setLastFocus } = unlock( useDispatch( blockEditorStore ) ); @@ -61,8 +59,7 @@ export default function useTabNav() { if ( getLastFocus()?.current ) { getLastFocus().current.focus(); } else { - // Handles when the last focus has not been set yet, or has - // been cleared by new blocks being added via the inserter. + // Handles when the last focus has not been set yet, or has been cleared by new blocks being added via the inserter. containerRef.current .querySelector( `[data-block="${ getSelectedBlockClientId() }"]` @@ -72,14 +69,12 @@ export default function useTabNav() { return; } - // In "compose" mode without a selected ID, place focus on the - // section root when entering the canvas. + // In "compose" mode without a selected ID, we want to place focus on the section root when tabbing to the canvas. if ( isZoomOut() ) { const sectionRootClientId = getSectionRootClientId(); const sectionBlocks = getBlockOrder( sectionRootClientId ); - // If we have a section within the section root, focus the first - // one. + // If we have section within the section root, focus the first one. if ( sectionBlocks.length ) { containerRef.current .querySelector( `[data-block="${ sectionBlocks[ 0 ] }"]` ) @@ -91,6 +86,7 @@ export default function useTabNav() { .querySelector( `[data-block="${ sectionRootClientId }"]` ) .focus(); } else { + // If we don't have any section root, focus the canvas. containerRef.current.focus(); } return; @@ -169,7 +165,11 @@ export default function useTabNav() { ref={ focusCaptureAfterRef } tabIndex="0" className="block-editor-writing-flow__canvas-stop-redirect" - onFocus={ () => focusCaptureBeforeRef.current?.focus() } + onFocus={ () => + focusCaptureBeforeRef.current?.focus( { + preventScroll: true, + } ) + } /> ); @@ -179,18 +179,6 @@ export default function useTabNav() { return; } - // Escape or Backspace right after an automatic change, like `* ` - // turning into a list, undoes that change. This needs nothing - // from the element the key landed on, only the store. - if ( event.key === 'Escape' || event.key === 'Backspace' ) { - const { __experimentalUndo } = getSettings(); - if ( __experimentalUndo && didAutomaticChange() ) { - event.preventDefault(); - __experimentalUndo(); - return; - } - } - // Escape steps out of the canvas onto its stop. Every handler // with a stronger claim on the key runs earlier, either deeper // in the tree or in the capture phase, so an unclaimed key here @@ -219,10 +207,10 @@ export default function useTabNav() { return; } - // Bails in case the focus capture elements aren’t present. They - // may be omitted to avoid silent tab stops in preview mode. - // See: https://github.com/WordPress/gutenberg/pull/59317 if ( + // Bails in case the focus capture elements aren’t present. They + // may be omitted to avoid silent tab stops in preview mode. + // See: https://github.com/WordPress/gutenberg/pull/59317 ! focusCaptureAfterRef.current || ! focusCaptureBeforeRef.current ) { @@ -290,9 +278,52 @@ export default function useTabNav() { } } + // When tabbing back to an element in block list, this event handler prevents scrolling if the + // focus capture divs (before/after) are outside of the viewport. (For example shift+tab back to a paragraph + // when focus is on a sidebar element. This prevents the scrollable writing area from jumping either to the + // top or bottom of the document. + // + // Note that it isn't possible to disable scrolling in the onFocus event. We need to intercept this + // earlier in the keypress handler, and call focus( { preventScroll: true } ) instead. + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/focus#parameters + function preventScrollOnTab( event ) { + // The canvas skip in `onKeyDown` may have already moved focus. + if ( event.defaultPrevented ) { + return; + } + + if ( event.keyCode !== TAB ) { + return; + } + + if ( event.target?.getAttribute( 'role' ) === 'region' ) { + return; + } + + if ( containerRef.current === event.target ) { + return; + } + + const isShift = event.shiftKey; + const direction = isShift ? 'findPrevious' : 'findNext'; + const target = focus.tabbable[ direction ]( event.target ); + // Only do something when the next tabbable is a focus capture div (before/after) + if ( + target === focusCaptureBeforeRef.current || + target === focusCaptureAfterRef.current + ) { + event.preventDefault(); + target.focus( { preventScroll: true } ); + } + } + + const { ownerDocument } = node; + const { defaultView } = ownerDocument; + defaultView.addEventListener( 'keydown', preventScrollOnTab ); node.addEventListener( 'keydown', onKeyDown ); node.addEventListener( 'focusout', onFocusOut ); return () => { + defaultView.removeEventListener( 'keydown', preventScrollOnTab ); node.removeEventListener( 'keydown', onKeyDown ); node.removeEventListener( 'focusout', onFocusOut ); }; diff --git a/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js b/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js new file mode 100644 index 00000000000000..c77b27d63ce7cd --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js @@ -0,0 +1,45 @@ +import { BACKSPACE, ESCAPE } from '@wordpress/keycodes'; +import { useSelect } from '@wordpress/data'; +import { useRefEffect } from '@wordpress/compose'; +import { store as blockEditorStore } from '../../store'; + +export default function useUndoAutomaticChange() { + const { didAutomaticChange, getSettings } = useSelect( blockEditorStore ); + + return useRefEffect( ( node ) => { + function onKeyDown( event ) { + const { keyCode } = event; + + if ( event.defaultPrevented ) { + return; + } + + if ( keyCode !== BACKSPACE && keyCode !== ESCAPE ) { + return; + } + + const { __experimentalUndo } = getSettings(); + + if ( ! __experimentalUndo ) { + return; + } + + if ( ! didAutomaticChange() ) { + return; + } + + event.preventDefault(); + __experimentalUndo(); + } + + // The undo claims the key ahead of fallbacks such as the Escape step + // out of the canvas, which listen in the bubble phase and yield to + // `defaultPrevented`. + node.addEventListener( 'keydown', onKeyDown, { capture: true } ); + return () => { + node.removeEventListener( 'keydown', onKeyDown, { + capture: true, + } ); + }; + }, [] ); +} From d52f65f9ad370fd8f90aad34b680afb008ee0d11 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 15:03:12 -0700 Subject: [PATCH 15/35] Span the stop and the redirect like trunk spans the capture elements Spanned elements are always at least partially in view, so no focus, native or scripted, ever scrolls the canvas. The window listener that prevented the scrolling is gone from trunk, and the preventScroll options served the same purpose. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../components/writing-flow/use-tab-nav.jsx | 55 ++----------------- 1 file changed, 4 insertions(+), 51 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 25ecae3dc668b7..cf6fd42ce57eb5 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -144,6 +144,7 @@ export default function useTabNav() { aria-label={ __( 'Editor canvas' ) } aria-describedby={ hintId } className="block-editor-writing-flow__canvas-stop" + style={ PREVENT_SCROLL_ON_FOCUS } onKeyDown={ onStopKeyDown } > { /* The badge doubles as the stop's description for assistive @@ -165,11 +166,8 @@ export default function useTabNav() { ref={ focusCaptureAfterRef } tabIndex="0" className="block-editor-writing-flow__canvas-stop-redirect" - onFocus={ () => - focusCaptureBeforeRef.current?.focus( { - preventScroll: true, - } ) - } + style={ PREVENT_SCROLL_ON_FOCUS } + onFocus={ () => focusCaptureBeforeRef.current?.focus() } /> ); @@ -191,9 +189,7 @@ export default function useTabNav() { ) { if ( focusCaptureBeforeRef.current ) { event.preventDefault(); - focusCaptureBeforeRef.current.focus( { - preventScroll: true, - } ); + focusCaptureBeforeRef.current.focus(); } return; } @@ -278,52 +274,9 @@ export default function useTabNav() { } } - // When tabbing back to an element in block list, this event handler prevents scrolling if the - // focus capture divs (before/after) are outside of the viewport. (For example shift+tab back to a paragraph - // when focus is on a sidebar element. This prevents the scrollable writing area from jumping either to the - // top or bottom of the document. - // - // Note that it isn't possible to disable scrolling in the onFocus event. We need to intercept this - // earlier in the keypress handler, and call focus( { preventScroll: true } ) instead. - // https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/focus#parameters - function preventScrollOnTab( event ) { - // The canvas skip in `onKeyDown` may have already moved focus. - if ( event.defaultPrevented ) { - return; - } - - if ( event.keyCode !== TAB ) { - return; - } - - if ( event.target?.getAttribute( 'role' ) === 'region' ) { - return; - } - - if ( containerRef.current === event.target ) { - return; - } - - const isShift = event.shiftKey; - const direction = isShift ? 'findPrevious' : 'findNext'; - const target = focus.tabbable[ direction ]( event.target ); - // Only do something when the next tabbable is a focus capture div (before/after) - if ( - target === focusCaptureBeforeRef.current || - target === focusCaptureAfterRef.current - ) { - event.preventDefault(); - target.focus( { preventScroll: true } ); - } - } - - const { ownerDocument } = node; - const { defaultView } = ownerDocument; - defaultView.addEventListener( 'keydown', preventScrollOnTab ); node.addEventListener( 'keydown', onKeyDown ); node.addEventListener( 'focusout', onFocusOut ); return () => { - defaultView.removeEventListener( 'keydown', preventScrollOnTab ); node.removeEventListener( 'keydown', onKeyDown ); node.removeEventListener( 'focusout', onFocusOut ); }; From 598f014fa937a770259dc634d00133516a0fc957 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 15:33:04 -0700 Subject: [PATCH 16/35] Keep trunk names and drop dead style rules The redirect handler keeps trunk's onFocusCapture name, the tab out target keeps next, the entry keys move to one list, and the position rules the spanned inline style overrides are gone. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/style.scss | 16 ++++--------- .../components/writing-flow/use-tab-nav.jsx | 23 +++++++++---------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index b2bfbabaf9d82f..f4be8f6ba868a7 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -2,25 +2,17 @@ @use "@wordpress/base-styles/variables" as *; .block-editor-writing-flow__canvas-stop { - /* The anchor for the focus ring and the hint. In the iframed editor the - positioned scale container is the anchor instead, so both stretch over - the canvas box rather than this zero-size element. */ - position: relative; outline: none; } -.block-editor-iframe__scale-container > .block-editor-writing-flow__canvas-stop { - position: static; -} - .block-editor-writing-flow__canvas-stop-redirect { outline: none; } -/* The stop element is zero-size, so its focus indication is drawn around the - canvas it stands for, right after it. For the frame it is an overlay - stretched over the positioned scale container: Safari paints an outline on - a frame element underneath the frame's content. */ +/* The stop element is invisible, so its focus indication is drawn around the + canvas it stands for. For the frame it is an overlay stretched over the + positioned scale container: Safari paints an outline on a frame element + underneath the frame's content. */ .block-editor-iframe__scale-container > .block-editor-writing-flow__canvas-stop:focus::after { content: ""; position: absolute; diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index cf6fd42ce57eb5..31ef7a100c86e6 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -20,6 +20,9 @@ const PREVENT_SCROLL_ON_FOCUS = { pointerEvents: 'none', }; +// Keys that move focus from the canvas stop into the canvas. +const ENTRY_KEYS = [ 'Enter', ' ', 'F2', 'Escape', 'ArrowDown', 'ArrowUp' ]; + export default function useTabNav() { const containerRef = /** @type {typeof useRef} */ ( useRef )(); const focusCaptureBeforeRef = useRef(); @@ -113,15 +116,7 @@ export default function useTabNav() { const { key } = event; - if ( - ! event.shiftKey && - ( key === 'Enter' || - key === ' ' || - key === 'F2' || - key === 'Escape' || - key === 'ArrowDown' || - key === 'ArrowUp' ) - ) { + if ( ! event.shiftKey && ENTRY_KEYS.includes( key ) ) { event.preventDefault(); enterCanvas( key === 'ArrowUp' ); return; @@ -161,13 +156,17 @@ export default function useTabNav() { // Focus arriving behind the canvas is forwarded to the stop before it, // so the canvas has one stop, reached from either direction. + function onFocusCapture() { + focusCaptureBeforeRef.current?.focus(); + } + const after = (
focusCaptureBeforeRef.current?.focus() } + onFocus={ onFocusCapture } /> ); @@ -245,10 +244,10 @@ export default function useTabNav() { // Tab out of the canvas: move focus past the element on the side // being tabbed towards, without stopping on it. event.preventDefault(); - const outside = isShift + const next = isShift ? focus.tabbable.findPrevious( focusCaptureBeforeRef.current ) : focus.tabbable.findNext( focusCaptureAfterRef.current ); - outside?.focus(); + next?.focus(); } function onFocusOut( event ) { From 6c9a43f6e9b57b9729a6cae8ce08fc7ff8236b02 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 15:39:14 -0700 Subject: [PATCH 17/35] Suppress the native outline in the shared style Both the stop and the redirect need it, which frees the redirect from carrying a class at all. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../block-editor/src/components/writing-flow/style.scss | 8 -------- .../src/components/writing-flow/use-tab-nav.jsx | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index f4be8f6ba868a7..0227855f957faa 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -1,14 +1,6 @@ @use "@wordpress/base-styles/colors" as *; @use "@wordpress/base-styles/variables" as *; -.block-editor-writing-flow__canvas-stop { - outline: none; -} - -.block-editor-writing-flow__canvas-stop-redirect { - outline: none; -} - /* The stop element is invisible, so its focus indication is drawn around the canvas it stands for. For the frame it is an overlay stretched over the positioned scale container: Safari paints an outline on a frame element diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 31ef7a100c86e6..c6ea327ad82ea1 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -18,6 +18,7 @@ const PREVENT_SCROLL_ON_FOCUS = { position: 'absolute', inset: 0, pointerEvents: 'none', + outline: 'none', }; // Keys that move focus from the canvas stop into the canvas. @@ -164,7 +165,6 @@ export default function useTabNav() {
From df1a8579c14bc2d69b55e3e6cf406e85f13da4af Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 15:45:38 -0700 Subject: [PATCH 18/35] Drop the arrow keys from the stop and restore the trunk branching Every kind of user already has an affordance on the stop: the visible hint, the button role, or the pointer. Arrow up in particular read as going up to the header, not to the end of the document. Without the directional entry, the trunk if/else chain fits again. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../components/writing-flow/use-tab-nav.jsx | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index c6ea327ad82ea1..9a32585893f2e7 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -22,7 +22,7 @@ const PREVENT_SCROLL_ON_FOCUS = { }; // Keys that move focus from the canvas stop into the canvas. -const ENTRY_KEYS = [ 'Enter', ' ', 'F2', 'Escape', 'ArrowDown', 'ArrowUp' ]; +const ENTRY_KEYS = [ 'Enter', ' ', 'F2', 'Escape' ]; export default function useTabNav() { const containerRef = /** @type {typeof useRef} */ ( useRef )(); @@ -47,7 +47,7 @@ export default function useTabNav() { // The canvas is a single stop in the page's tab order, and going in is // an explicit action on it. Focus returns to the place it last left the // canvas from. - function enterCanvas( fromAfter ) { + function enterCanvas() { // Focus the canvas window before any element in it: entering starts // from the stop in the parent document, and Firefox does not move // window focus for a cross-document `element.focus()`, leaving the @@ -56,10 +56,7 @@ export default function useTabNav() { if ( hasMultiSelection() ) { containerRef.current.focus(); - return; - } - - if ( getSelectedBlockClientId() ) { + } else if ( getSelectedBlockClientId() ) { if ( getLastFocus()?.current ) { getLastFocus().current.focus(); } else { @@ -70,11 +67,9 @@ export default function useTabNav() { ) .focus(); } - return; } - // In "compose" mode without a selected ID, we want to place focus on the section root when tabbing to the canvas. - if ( isZoomOut() ) { + else if ( isZoomOut() ) { const sectionRootClientId = getSectionRootClientId(); const sectionBlocks = getBlockOrder( sectionRootClientId ); @@ -93,15 +88,11 @@ export default function useTabNav() { // If we don't have any section root, focus the canvas. containerRef.current.focus(); } - return; - } - - const tabbables = focus.tabbable.find( containerRef.current ); - if ( tabbables.length ) { - const next = fromAfter - ? tabbables[ tabbables.length - 1 ] - : tabbables[ 0 ]; - next.focus(); + } else { + const tabbables = focus.tabbable.find( containerRef.current ); + if ( tabbables.length ) { + tabbables[ 0 ].focus(); + } } } @@ -119,7 +110,7 @@ export default function useTabNav() { if ( ! event.shiftKey && ENTRY_KEYS.includes( key ) ) { event.preventDefault(); - enterCanvas( key === 'ArrowUp' ); + enterCanvas(); return; } From 904a01cb7e06102573a4a1f28069c389d173ac6c Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 15:48:36 -0700 Subject: [PATCH 19/35] Say what the scale container anchors: the stop, not the ring The ring and the hint anchor to the stop since it started spanning the canvas; the scale container anchors the stop itself. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- packages/block-editor/src/components/iframe/style.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/iframe/style.scss b/packages/block-editor/src/components/iframe/style.scss index 80f0d81e06eba5..04a67b88583f22 100644 --- a/packages/block-editor/src/components/iframe/style.scss +++ b/packages/block-editor/src/components/iframe/style.scss @@ -12,7 +12,8 @@ .block-editor-iframe__scale-container { height: 100%; - /* The anchor for the focus ring and hint of the canvas stop inside. */ + /* The canvas stop inside stretches over the nearest positioned ancestor, + and this box is the one that hugs the frame in every mode. */ position: relative; } From 5f258b450584bc95b405c15dd07d2faa53c1034c Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 15:51:55 -0700 Subject: [PATCH 20/35] Shape the moved undo listener like the original The handler sits at the top level again, so the body reads unchanged from the rich text file it moved from; only the imports and the subscription differ. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../writing-flow/use-undo-automatic-change.js | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js b/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js index c77b27d63ce7cd..eb4a556c7dd3ab 100644 --- a/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js +++ b/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js @@ -6,38 +6,38 @@ import { store as blockEditorStore } from '../../store'; export default function useUndoAutomaticChange() { const { didAutomaticChange, getSettings } = useSelect( blockEditorStore ); - return useRefEffect( ( node ) => { - function onKeyDown( event ) { - const { keyCode } = event; + function onKeyDown( event ) { + const { keyCode } = event; - if ( event.defaultPrevented ) { - return; - } - - if ( keyCode !== BACKSPACE && keyCode !== ESCAPE ) { - return; - } + if ( event.defaultPrevented ) { + return; + } - const { __experimentalUndo } = getSettings(); + if ( keyCode !== BACKSPACE && keyCode !== ESCAPE ) { + return; + } - if ( ! __experimentalUndo ) { - return; - } + const { __experimentalUndo } = getSettings(); - if ( ! didAutomaticChange() ) { - return; - } + if ( ! __experimentalUndo ) { + return; + } - event.preventDefault(); - __experimentalUndo(); + if ( ! didAutomaticChange() ) { + return; } + event.preventDefault(); + __experimentalUndo(); + } + + return useRefEffect( ( element ) => { // The undo claims the key ahead of fallbacks such as the Escape step // out of the canvas, which listen in the bubble phase and yield to // `defaultPrevented`. - node.addEventListener( 'keydown', onKeyDown, { capture: true } ); + element.addEventListener( 'keydown', onKeyDown, { capture: true } ); return () => { - node.removeEventListener( 'keydown', onKeyDown, { + element.removeEventListener( 'keydown', onKeyDown, { capture: true, } ); }; From 30634889b7ffb8df1609c44fe3b5af070a249db4 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 15:54:54 -0700 Subject: [PATCH 21/35] Let the resizable editor container anchor the stop Its box already hugs the canvas in every mode, measured in the default and device preview layouts, and the zoomed out scale container is positioned on its own. The added rule was redundant. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- packages/block-editor/src/components/iframe/style.scss | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/block-editor/src/components/iframe/style.scss b/packages/block-editor/src/components/iframe/style.scss index 04a67b88583f22..0daa285a7b1f4b 100644 --- a/packages/block-editor/src/components/iframe/style.scss +++ b/packages/block-editor/src/components/iframe/style.scss @@ -12,9 +12,6 @@ .block-editor-iframe__scale-container { height: 100%; - /* The canvas stop inside stretches over the nearest positioned ancestor, - and this box is the one that hugs the frame in every mode. */ - position: relative; } .block-editor-iframe__scale-container.is-zoomed-out { From 2d5eb596efd96972e48f99c63f7fcbf920daecfa Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 15:59:13 -0700 Subject: [PATCH 22/35] Order the undo before the tab-nav listeners instead of capturing Same-node listeners run in registration order, so registering the undo hook first is enough for it to claim Escape ahead of the step out; a comment at the registration records the dependency. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../block-editor/src/components/writing-flow/index.jsx | 5 ++++- .../components/writing-flow/use-undo-automatic-change.js | 9 ++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/index.jsx b/packages/block-editor/src/components/writing-flow/index.jsx index 5e29811cd3f33f..5f5f4d38841788 100644 --- a/packages/block-editor/src/components/writing-flow/index.jsx +++ b/packages/block-editor/src/components/writing-flow/index.jsx @@ -29,8 +29,11 @@ export function useWritingFlow() { return [ before, useMergeRefs( [ - ref, + // Same-node listeners run in registration order, and the undo of + // an automatic change must claim Escape before the tab-nav + // listeners step out of the canvas. useUndoAutomaticChange(), + ref, useEditableRootEventHandlers(), useClipboardHandler(), useInput(), diff --git a/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js b/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js index eb4a556c7dd3ab..3e4785230bfaa1 100644 --- a/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js +++ b/packages/block-editor/src/components/writing-flow/use-undo-automatic-change.js @@ -32,14 +32,9 @@ export default function useUndoAutomaticChange() { } return useRefEffect( ( element ) => { - // The undo claims the key ahead of fallbacks such as the Escape step - // out of the canvas, which listen in the bubble phase and yield to - // `defaultPrevented`. - element.addEventListener( 'keydown', onKeyDown, { capture: true } ); + element.addEventListener( 'keydown', onKeyDown ); return () => { - element.removeEventListener( 'keydown', onKeyDown, { - capture: true, - } ); + element.removeEventListener( 'keydown', onKeyDown ); }; }, [] ); } From 7f180da439de9c521a573c704c24258b7e7fc191 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 16:05:50 -0700 Subject: [PATCH 23/35] Keep the after element on its trunk lines The attribute order matched trunk except for the swapped style and focus handler, which made the whole element show as changed. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../block-editor/src/components/writing-flow/use-tab-nav.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 9a32585893f2e7..400f7dd3690839 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -156,8 +156,8 @@ export default function useTabNav() {
); From b90966aea4bd6076684b7c5be1aa03cf898e02f9 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 16:12:30 -0700 Subject: [PATCH 24/35] Assert the undo priority in the existing list test The asterisk transform undo already has a test; the piece this branch adds is that the claimed Escape must not also step out of the canvas. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- test/e2e/specs/editor/blocks/list.spec.js | 5 ++++ .../editor/various/canvas-tab-stop.spec.js | 24 ------------------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/test/e2e/specs/editor/blocks/list.spec.js b/test/e2e/specs/editor/blocks/list.spec.js index 4fad57a11c31ae..5b18340766f1ed 100644 --- a/test/e2e/specs/editor/blocks/list.spec.js +++ b/test/e2e/specs/editor/blocks/list.spec.js @@ -210,6 +210,11 @@ test.describe( 'List (@firefox)', () => {

*

` ); + // The undo claims the Escape; it must not also step out of the + // canvas onto its stop. + await expect( + page.getByRole( 'button', { name: 'Editor canvas' } ).first() + ).not.toBeFocused(); } ); test( 'should not undo asterisk transform with backspace after typing', async ( { diff --git a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js index 0424079e9a3e6a..cd0720fe7f5b00 100644 --- a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js +++ b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js @@ -118,28 +118,4 @@ test.describe( 'Canvas as a single tab stop', () => { ) .toBe( true ); } ); - - test( 'still undoes an automatic change first', async ( { - editor, - page, - } ) => { - await editor.canvas - .locator( 'role=document[name="Add default block"i]' ) - .click(); - await page.keyboard.type( '* ' ); - await expect - .poll( editor.getBlocks ) - .toMatchObject( [ { name: 'core/list' } ] ); - - await page.keyboard.press( 'Escape' ); - await expect - .poll( editor.getBlocks ) - .toMatchObject( [ - { name: 'core/paragraph', attributes: { content: '* ' } }, - ] ); - await expect.poll( () => hasTextSelection( page ) ).toBe( true ); - await expect( - page.getByRole( 'button', { name: 'Editor canvas' } ).first() - ).not.toBeFocused(); - } ); } ); From 66c057f987f19226b6c63c8ea9b371c47b5fe0fa Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 17:02:40 -0700 Subject: [PATCH 25/35] Fold the tab hops into the Escape journey Crossing the canvas from outside is asserted by the existing keyboard navigation suites; what is left for this spec is the stop's own key handling, now one walk: out, past the canvas both ways, and back in. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../editor/various/canvas-tab-stop.spec.js | 85 ++++++++----------- 1 file changed, 36 insertions(+), 49 deletions(-) diff --git a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js index cd0720fe7f5b00..f59a14036ebe4b 100644 --- a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js +++ b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js @@ -17,6 +17,7 @@ test.describe( 'Canvas as a single tab stop', () => { editor, page, } ) => { + await editor.openDocumentSettingsSidebar(); await editor.insertBlock( { name: 'core/paragraph', attributes: { content: 'First' }, @@ -41,7 +42,41 @@ test.describe( 'Canvas as a single tab stop', () => { { name: 'core/paragraph', attributes: { content: 'First' } }, ] ); - // Enter goes back to where focus left the canvas. + // Tab from the stop skips the whole canvas onwards. + await page.keyboard.press( 'Tab' ); + await expect + .poll( () => + page.evaluate( () => + Boolean( + document.activeElement.closest( + '[aria-label="Editor settings"]' + ) + ) + ) + ) + .toBe( true ); + + // Shift+Tab from the sidebar lands back on the stop, and another + // Shift+Tab skips the whole canvas backwards, onto the block toolbar + // floating over it. + await page.keyboard.press( 'Shift+Tab' ); + await expect( stopBefore ).toBeFocused(); + await page.keyboard.press( 'Shift+Tab' ); + await expect + .poll( () => + page.evaluate( () => + Boolean( + document.activeElement.closest( + '[aria-label="Block tools"]' + ) + ) + ) + ) + .toBe( true ); + + // Enter on the stop goes back to where focus left the canvas. + await page.keyboard.press( 'Tab' ); + await expect( stopBefore ).toBeFocused(); await page.keyboard.press( 'Enter' ); await expect( editor.canvas.getByRole( 'document', { @@ -70,52 +105,4 @@ test.describe( 'Canvas as a single tab stop', () => { } ) ).toBeFocused(); } ); - - test( 'Tab moves past the canvas in both directions', async ( { - editor, - page, - } ) => { - await editor.openDocumentSettingsSidebar(); - await editor.insertBlock( { - name: 'core/paragraph', - attributes: { content: 'First' }, - } ); - await editor.canvas.getByText( 'First' ).click(); - - // Out onto the stop, then Tab skips the whole canvas onwards. - await page.keyboard.press( 'Escape' ); - await page.keyboard.press( 'Tab' ); - await expect - .poll( () => - page.evaluate( () => - Boolean( - document.activeElement.closest( - '[aria-label="Editor settings"]' - ) - ) - ) - ) - .toBe( true ); - - // Shift+Tab from the sidebar lands on the stop after the canvas. - await page.keyboard.press( 'Shift+Tab' ); - await expect( - page.getByRole( 'button', { name: 'Editor canvas' } ).last() - ).toBeFocused(); - - // Shift+Tab again skips the whole canvas backwards, onto the block - // toolbar floating over it. - await page.keyboard.press( 'Shift+Tab' ); - await expect - .poll( () => - page.evaluate( () => - Boolean( - document.activeElement.closest( - '[aria-label="Block tools"]' - ) - ) - ) - ) - .toBe( true ); - } ); } ); From 5ceda7b8c30db56d0e0c81190907817bd4696cce Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 17:05:41 -0700 Subject: [PATCH 26/35] House the canvas stop tests with the keyboard navigation suite Two tests do not carry a file; the suite about focus order is where they belong. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../editor/various/canvas-tab-stop.spec.js | 108 ------------------ .../various/keyboard-navigable-blocks.spec.js | 107 +++++++++++++++++ 2 files changed, 107 insertions(+), 108 deletions(-) delete mode 100644 test/e2e/specs/editor/various/canvas-tab-stop.spec.js diff --git a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js b/test/e2e/specs/editor/various/canvas-tab-stop.spec.js deleted file mode 100644 index f59a14036ebe4b..00000000000000 --- a/test/e2e/specs/editor/various/canvas-tab-stop.spec.js +++ /dev/null @@ -1,108 +0,0 @@ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); - -test.describe( 'Canvas as a single tab stop', () => { - test.beforeEach( async ( { admin } ) => { - await admin.createNewPost(); - } ); - - async function hasTextSelection( page ) { - return page.evaluate( () => { - const { getSelectionStart } = - window.wp.data.select( 'core/block-editor' ); - return getSelectionStart().attributeKey !== undefined; - } ); - } - - test( 'Escape steps out onto the canvas stop and Enter goes back in', async ( { - editor, - page, - } ) => { - await editor.openDocumentSettingsSidebar(); - await editor.insertBlock( { - name: 'core/paragraph', - attributes: { content: 'First' }, - } ); - await editor.canvas.getByText( 'First' ).click(); - await expect.poll( () => hasTextSelection( page ) ).toBe( true ); - - await page.keyboard.press( 'Escape' ); - const stopBefore = page - .getByRole( 'button', { name: 'Editor canvas' } ) - .first(); - await expect( stopBefore ).toBeFocused(); - // The focused stop shows its hint badge. - await expect( - page.getByText( 'Press Enter to edit the document' ).first() - ).toBeVisible(); - - // The block selection is left as it is. - await expect - .poll( editor.getBlocks ) - .toMatchObject( [ - { name: 'core/paragraph', attributes: { content: 'First' } }, - ] ); - - // Tab from the stop skips the whole canvas onwards. - await page.keyboard.press( 'Tab' ); - await expect - .poll( () => - page.evaluate( () => - Boolean( - document.activeElement.closest( - '[aria-label="Editor settings"]' - ) - ) - ) - ) - .toBe( true ); - - // Shift+Tab from the sidebar lands back on the stop, and another - // Shift+Tab skips the whole canvas backwards, onto the block toolbar - // floating over it. - await page.keyboard.press( 'Shift+Tab' ); - await expect( stopBefore ).toBeFocused(); - await page.keyboard.press( 'Shift+Tab' ); - await expect - .poll( () => - page.evaluate( () => - Boolean( - document.activeElement.closest( - '[aria-label="Block tools"]' - ) - ) - ) - ) - .toBe( true ); - - // Enter on the stop goes back to where focus left the canvas. - await page.keyboard.press( 'Tab' ); - await expect( stopBefore ).toBeFocused(); - await page.keyboard.press( 'Enter' ); - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Paragraph', - } ) - ).toBeFocused(); - await expect.poll( () => hasTextSelection( page ) ).toBe( true ); - } ); - - test( 'Escape on the stop goes back in too', async ( { editor, page } ) => { - await editor.insertBlock( { - name: 'core/paragraph', - attributes: { content: 'First' }, - } ); - await editor.canvas.getByText( 'First' ).click(); - - await page.keyboard.press( 'Escape' ); - await expect( - page.getByRole( 'button', { name: 'Editor canvas' } ).first() - ).toBeFocused(); - - await page.keyboard.press( 'Escape' ); - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Paragraph', - } ) - ).toBeFocused(); - } ); -} ); 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 627206191bd4cf..54988cf6c81a1e 100644 --- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js +++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js @@ -109,6 +109,113 @@ test.describe( 'Order of block keyboard navigation', () => { } ); } ); +test.describe( 'Canvas as a single tab stop', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + async function hasTextSelection( page ) { + return page.evaluate( () => { + const { getSelectionStart } = + window.wp.data.select( 'core/block-editor' ); + return getSelectionStart().attributeKey !== undefined; + } ); + } + + test( 'Escape steps out onto the canvas stop and Enter goes back in', async ( { + editor, + page, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First' }, + } ); + await editor.canvas.getByText( 'First' ).click(); + await expect.poll( () => hasTextSelection( page ) ).toBe( true ); + + await page.keyboard.press( 'Escape' ); + const stopBefore = page + .getByRole( 'button', { name: 'Editor canvas' } ) + .first(); + await expect( stopBefore ).toBeFocused(); + // The focused stop shows its hint badge. + await expect( + page.getByText( 'Press Enter to edit the document' ).first() + ).toBeVisible(); + + // The block selection is left as it is. + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/paragraph', attributes: { content: 'First' } }, + ] ); + + // Tab from the stop skips the whole canvas onwards. + await page.keyboard.press( 'Tab' ); + await expect + .poll( () => + page.evaluate( () => + Boolean( + document.activeElement.closest( + '[aria-label="Editor settings"]' + ) + ) + ) + ) + .toBe( true ); + + // Shift+Tab from the sidebar lands back on the stop, and another + // Shift+Tab skips the whole canvas backwards, onto the block toolbar + // floating over it. + await page.keyboard.press( 'Shift+Tab' ); + await expect( stopBefore ).toBeFocused(); + await page.keyboard.press( 'Shift+Tab' ); + await expect + .poll( () => + page.evaluate( () => + Boolean( + document.activeElement.closest( + '[aria-label="Block tools"]' + ) + ) + ) + ) + .toBe( true ); + + // Enter on the stop goes back to where focus left the canvas. + await page.keyboard.press( 'Tab' ); + await expect( stopBefore ).toBeFocused(); + await page.keyboard.press( 'Enter' ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ) + ).toBeFocused(); + await expect.poll( () => hasTextSelection( page ) ).toBe( true ); + } ); + + test( 'Escape on the stop goes back in too', async ( { editor, page } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First' }, + } ); + await editor.canvas.getByText( 'First' ).click(); + + await page.keyboard.press( 'Escape' ); + await expect( + page.getByRole( 'button', { name: 'Editor canvas' } ).first() + ).toBeFocused(); + + await page.keyboard.press( 'Escape' ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ) + ).toBeFocused(); + } ); +} ); + class KeyboardNavigableBlocks { constructor( { editor, page, pageUtils } ) { this.editor = editor; From bca571447cb2fd42c45699f34caba57e1b59d57d Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 17:20:42 -0700 Subject: [PATCH 27/35] Enter the canvas on click too, and say things more plainly Assistive technology activates a button with a click event, not a key press, and pointer clicks pass through the stop, so a click can only be such an activation. Also: Shift+Escape no longer steps out, the hint radius uses a base styles token that exists on every screen, the test locators now assert there is only one stop, and several comments say what happens in plainer words, including the third kind of stronger claim on Escape: listeners registered earlier on the same node. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/style.scss | 12 +++--- .../components/writing-flow/use-tab-nav.jsx | 39 +++++++++++-------- test/e2e/specs/editor/blocks/list.spec.js | 2 +- .../various/keyboard-navigable-blocks.spec.js | 10 ++--- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index 0227855f957faa..9b6222d4036f23 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -2,9 +2,9 @@ @use "@wordpress/base-styles/variables" as *; /* The stop element is invisible, so its focus indication is drawn around the - canvas it stands for. For the frame it is an overlay stretched over the - positioned scale container: Safari paints an outline on a frame element - underneath the frame's content. */ + canvas it stands for. For the iframed canvas it is an overlay instead of + an outline, because Safari paints an outline on an iframe element + underneath the iframe's content. */ .block-editor-iframe__scale-container > .block-editor-writing-flow__canvas-stop:focus::after { content: ""; position: absolute; @@ -19,8 +19,8 @@ outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); } -/* Styled like a tooltip; shown while the stop is focused, at the top of the - canvas. Doubles as the stop's description for assistive technologies. */ +/* Looks like a tooltip. Shown at the top of the canvas while the stop is + focused. */ .block-editor-writing-flow__canvas-stop-hint { display: none; position: absolute; @@ -30,7 +30,7 @@ z-index: 2; background: $black; font-family: $default-font; - border-radius: var(--wpds-border-radius-md); + border-radius: $radius-medium; color: $gray-100; text-align: center; line-height: 1.4; diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 400f7dd3690839..bfd5de9ac2de26 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -48,10 +48,10 @@ export default function useTabNav() { // an explicit action on it. Focus returns to the place it last left the // canvas from. function enterCanvas() { - // Focus the canvas window before any element in it: entering starts - // from the stop in the parent document, and Firefox does not move - // window focus for a cross-document `element.focus()`, leaving the - // caret restored but every key stranded in the parent. + // Focus the canvas window first. The stop is in the parent document, + // and when Firefox focuses an element in another document, it does + // not move window focus, so the caret would be restored but key + // presses would keep going to the parent page. containerRef.current.ownerDocument.defaultView.focus(); if ( hasMultiSelection() ) { @@ -114,9 +114,9 @@ export default function useTabNav() { return; } - // Backwards, Tab leaves the canvas naturally: the stop is the first - // element before it. Forwards, sequential order would walk into the - // content, so move focus to the first tabbable past the canvas. + // Shift+Tab leaves the canvas on its own, because the stop is the + // first element before it. Plain Tab would move into the content, + // so move focus to the first tabbable element past the canvas. if ( key === 'Tab' && ! event.shiftKey ) { event.preventDefault(); focus.tabbable.findNext( focusCaptureAfterRef.current )?.focus(); @@ -133,10 +133,14 @@ export default function useTabNav() { className="block-editor-writing-flow__canvas-stop" style={ PREVENT_SCROLL_ON_FOCUS } onKeyDown={ onStopKeyDown } + // Assistive technology activates a button with a click event, + // not a key press. Pointer clicks pass through the element, so + // a click can only come from such an activation. + onClick={ enterCanvas } > - { /* The badge doubles as the stop's description for assistive - technologies; hidden content still counts for - `aria-describedby`. */ } + { /* The hint is also the button's description for assistive + technology. Content hidden with `display: none` still + works for `aria-describedby`. */ }
); - // Focus arriving behind the canvas is forwarded to the stop before it, - // so the canvas has one stop, reached from either direction. + // Focus landing on the element after the canvas is forwarded to the + // stop before it, so there is one stop no matter which direction it is + // reached from. function onFocusCapture() { focusCaptureBeforeRef.current?.focus(); } @@ -167,12 +172,14 @@ export default function useTabNav() { return; } - // Escape steps out of the canvas onto its stop. Every handler - // with a stronger claim on the key runs earlier, either deeper - // in the tree or in the capture phase, so an unclaimed key here - // means the key is free. + // Escape steps out of the canvas onto its stop. Everything with + // a stronger claim on the key runs first: handlers deeper in + // the tree, capture handlers, and listeners registered earlier + // on this node, like the automatic change undo. If the key is + // still unclaimed here, nothing else wants it. if ( event.key === 'Escape' && + ! event.shiftKey && ! event.ctrlKey && ! event.metaKey && ! event.altKey diff --git a/test/e2e/specs/editor/blocks/list.spec.js b/test/e2e/specs/editor/blocks/list.spec.js index 5b18340766f1ed..1ad41656c423d1 100644 --- a/test/e2e/specs/editor/blocks/list.spec.js +++ b/test/e2e/specs/editor/blocks/list.spec.js @@ -213,7 +213,7 @@ test.describe( 'List (@firefox)', () => { // The undo claims the Escape; it must not also step out of the // canvas onto its stop. await expect( - page.getByRole( 'button', { name: 'Editor canvas' } ).first() + page.getByRole( 'button', { name: 'Editor canvas' } ) ).not.toBeFocused(); } ); 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 54988cf6c81a1e..de215b0fdac113 100644 --- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js +++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js @@ -135,13 +135,13 @@ test.describe( 'Canvas as a single tab stop', () => { await expect.poll( () => hasTextSelection( page ) ).toBe( true ); await page.keyboard.press( 'Escape' ); - const stopBefore = page - .getByRole( 'button', { name: 'Editor canvas' } ) - .first(); + const stopBefore = page.getByRole( 'button', { + name: 'Editor canvas', + } ); await expect( stopBefore ).toBeFocused(); // The focused stop shows its hint badge. await expect( - page.getByText( 'Press Enter to edit the document' ).first() + page.getByText( 'Press Enter to edit the document' ) ).toBeVisible(); // The block selection is left as it is. @@ -204,7 +204,7 @@ test.describe( 'Canvas as a single tab stop', () => { await page.keyboard.press( 'Escape' ); await expect( - page.getByRole( 'button', { name: 'Editor canvas' } ).first() + page.getByRole( 'button', { name: 'Editor canvas' } ) ).toBeFocused(); await page.keyboard.press( 'Escape' ); From 23a12e31768a0753731e31dd1901fc179cded88e Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 19:36:26 -0700 Subject: [PATCH 28/35] Let Escape claimants in the parent document run before the step out With a multi selection, Escape collapses it to one block, and while a content-only section is edited, Escape stops editing it. Both handlers live in the parent document and see the key after the canvas listeners, so the step out now yields to them. Tab out of the canvas goes through the capture elements again, letting the browser's own Tab move on from there. Computing the target with the tabbable finder missed the block toolbar, which places focus itself when approached. The specs that walked straight from the toolbar or a popover into the content now cross the canvas stop with an extra Enter, and the specs locating buttons named Edit no longer match the stop's Editor canvas label. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../components/writing-flow/use-tab-nav.jsx | 35 +++++++++++++++---- test/e2e/specs/editor/blocks/buttons.spec.js | 4 +-- test/e2e/specs/editor/blocks/math.spec.js | 8 +++-- .../various/format-library/math.spec.js | 4 ++- .../editor/various/navigable-toolbar.spec.js | 22 +++++++++--- .../specs/editor/various/rich-text.spec.js | 7 +++- test/e2e/specs/site-editor/navigation.spec.js | 1 + .../specs/widgets/customizing-widgets.spec.js | 2 +- 8 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index bfd5de9ac2de26..97451211bfd8a7 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -40,10 +40,15 @@ export default function useTabNav() { getBlockOrder, getLastFocus, getSectionRootClientId, + getEditedContentOnlySection, isZoomOut, } = unlock( useSelect( blockEditorStore ) ); const { setLastFocus } = unlock( useDispatch( blockEditorStore ) ); + // Reference that holds the a flag for enabling or disabling + // capturing on the focus capture elements. + const noCaptureRef = useRef(); + // The canvas is a single stop in the page's tab order, and going in is // an explicit action on it. Focus returns to the place it last left the // canvas from. @@ -154,6 +159,13 @@ export default function useTabNav() { // stop before it, so there is one stop no matter which direction it is // reached from. function onFocusCapture() { + // Do not forward focus set by the tab handler below, which moves + // focus here so that the default behaviour (moving focus to the + // next tabbable element) continues from this element. + if ( noCaptureRef.current ) { + noCaptureRef.current = null; + return; + } focusCaptureBeforeRef.current?.focus(); } @@ -184,6 +196,14 @@ export default function useTabNav() { ! event.metaKey && ! event.altKey ) { + // When multiple blocks are selected, Escape first collapses + // the selection to a single block, and when a content-only + // section is being edited, Escape first stops editing it. + // Those handlers live in the parent document and run after + // this one, so leave the key to them. + if ( hasMultiSelection() || getEditedContentOnlySection() ) { + return; + } if ( focusCaptureBeforeRef.current ) { event.preventDefault(); focusCaptureBeforeRef.current.focus(); @@ -239,13 +259,14 @@ export default function useTabNav() { return; } - // Tab out of the canvas: move focus past the element on the side - // being tabbed towards, without stopping on it. - event.preventDefault(); - const next = isShift - ? focus.tabbable.findPrevious( focusCaptureBeforeRef.current ) - : focus.tabbable.findNext( focusCaptureAfterRef.current ); - next?.focus(); + const next = isShift ? focusCaptureBeforeRef : focusCaptureAfterRef; + + // Disable the focus forwarding on the focus capture element, so + // it allows default behaviour (moving focus to the next tabbable + // element). + noCaptureRef.current = ! isShift; + + next.current.focus(); } function onFocusOut( event ) { diff --git a/test/e2e/specs/editor/blocks/buttons.spec.js b/test/e2e/specs/editor/blocks/buttons.spec.js index 5e196c72b93f9a..ade01be413950a 100644 --- a/test/e2e/specs/editor/blocks/buttons.spec.js +++ b/test/e2e/specs/editor/blocks/buttons.spec.js @@ -214,7 +214,7 @@ test.describe( 'Buttons', () => { await page.keyboard.press( 'Enter' ); // Edit link. - await page.getByRole( 'button', { name: 'Edit' } ).click(); + await page.getByRole( 'button', { name: 'Edit link' } ).click(); // Open Advanced settings panel. await page @@ -261,7 +261,7 @@ test.describe( 'Buttons', () => { ] ); // Edit link again. - await page.getByRole( 'button', { name: 'Edit' } ).click(); + await page.getByRole( 'button', { name: 'Edit link' } ).click(); // Navigate to and toggle the "nofollow" checkbox. await noFollowCheckbox.click(); diff --git a/test/e2e/specs/editor/blocks/math.spec.js b/test/e2e/specs/editor/blocks/math.spec.js index a21c2938b70523..8b5f2d3aabe944 100644 --- a/test/e2e/specs/editor/blocks/math.spec.js +++ b/test/e2e/specs/editor/blocks/math.spec.js @@ -25,9 +25,11 @@ test.describe( 'Math Block', () => { }, ] ); - // Can escape the popover. + // Can escape the popover. Focus lands on the canvas stop, and Enter + // moves it back to the block. await pageUtils.pressKeys( 'shift+Tab' ); await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'b' ); expect( await editor.getBlocks() ).toMatchObject( [ @@ -75,8 +77,10 @@ test.describe( 'Math Block', () => { // Fix syntax error. await page.keyboard.press( 'Backspace' ); - // Can delete the math block. + // Can delete the math block. Focus lands on the canvas stop, and + // Enter moves it back to the block. await pageUtils.pressKeys( 'shift+Tab' ); + await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Backspace' ); expect( await editor.getBlocks() ).toMatchObject( [ diff --git a/test/e2e/specs/editor/various/format-library/math.spec.js b/test/e2e/specs/editor/various/format-library/math.spec.js index fef8bf541d8309..4e8d35bc359451 100644 --- a/test/e2e/specs/editor/various/format-library/math.spec.js +++ b/test/e2e/specs/editor/various/format-library/math.spec.js @@ -41,8 +41,10 @@ test.describe( 'Format Library - Math', () => { }, ] ); - // Test typing before. + // Test typing before. Focus lands on the canvas stop, and Enter + // moves it back to the text. await pageUtils.pressKeys( 'shift+Tab' ); + await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'ArrowLeft' ); await page.keyboard.type( 'a' ); diff --git a/test/e2e/specs/editor/various/navigable-toolbar.spec.js b/test/e2e/specs/editor/various/navigable-toolbar.spec.js index c657a2b234f072..e85d38a8c07023 100644 --- a/test/e2e/specs/editor/various/navigable-toolbar.spec.js +++ b/test/e2e/specs/editor/various/navigable-toolbar.spec.js @@ -162,6 +162,10 @@ test.describe( 'Block Toolbar', () => { ).toBeFocused(); await pageUtils.pressKeys( 'Tab' ); + await expect + .poll( editor.getFocusOwnerLabel ) + .toBe( 'Editor canvas' ); + await pageUtils.pressKeys( 'Enter' ); await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Upload' ); } ); @@ -291,7 +295,9 @@ test.describe( 'Block Toolbar', () => { } ); await expect( blockToolbarParagraphButton ).toBeFocused(); await pageUtils.pressKeys( 'Tab' ); - // check focus is on the block + // Focus lands on the canvas stop, and Enter moves it to the block. + await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Editor canvas' ); + await pageUtils.pressKeys( 'Enter' ); await expect .poll( editor.getFocusOwnerLabel ) .toBe( 'Block: Paragraph' ); @@ -307,7 +313,9 @@ test.describe( 'Block Toolbar', () => { // check focus is within the block toolbar await expect( blockToolbarParagraphButton ).toBeFocused(); await pageUtils.pressKeys( 'Tab' ); - // check focus is on the block + // Focus lands on the canvas stop, and Enter moves it to the block. + await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Editor canvas' ); + await pageUtils.pressKeys( 'Enter' ); await expect .poll( editor.getFocusOwnerLabel ) .toBe( 'Block: Paragraph' ); @@ -322,7 +330,9 @@ test.describe( 'Block Toolbar', () => { await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Options' ); await pageUtils.pressKeys( 'Tab' ); - // check focus is on the block + // Focus lands on the canvas stop, and Enter moves it to the block. + await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Editor canvas' ); + await pageUtils.pressKeys( 'Enter' ); await expect .poll( editor.getFocusOwnerLabel ) .toBe( 'Block: Paragraph' ); @@ -343,7 +353,9 @@ test.describe( 'Block Toolbar', () => { // check focus is within the block toolbar await expect( blockToolbarParagraphButton ).toBeFocused(); await pageUtils.pressKeys( 'Tab' ); - // check focus is on the block + // Focus lands on the canvas stop, and Enter moves it to the block. + await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Editor canvas' ); + await pageUtils.pressKeys( 'Enter' ); await expect .poll( editor.getFocusOwnerLabel ) .toBe( 'Block: Paragraph' ); @@ -395,6 +407,8 @@ test.describe( 'Block Toolbar', () => { // Check to make sure focus returns to the Move Up button roving index after all of this await pageUtils.pressKeys( 'Tab' ); + // Focus lands on the canvas stop, and Enter moves it to the block. + await pageUtils.pressKeys( 'Enter' ); // Hide the block toolbar await pageUtils.pressKeys( 'ArrowRight' ); // Check the block toolbar is hidden diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index 22a833cae2b6fc..8ddf5c3a2ad203 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -460,6 +460,9 @@ test.describe( 'RichText (@firefox, @webkit)', () => { await page.keyboard.type( '1' ); await page.keyboard.press( 'Tab' ); await pageUtils.pressKeys( 'shift+Tab' ); + // Focus lands on the canvas stop, and Enter moves it back to the + // text. + await page.keyboard.press( 'Enter' ); await pageUtils.pressKeys( 'primary+b' ); await page.keyboard.type( '2' ); await pageUtils.pressKeys( 'primary+b' ); @@ -673,8 +676,10 @@ test.describe( 'RichText (@firefox, @webkit)', () => { // Dismiss color picker popover. await page.keyboard.press( 'Escape' ); - // Navigate to the block. + // Navigate to the block. Tab moves focus to the canvas stop, and + // Enter moves it back to the text. await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Enter' ); // Copy the colored text. await pageUtils.pressKeys( 'primary+c' ); diff --git a/test/e2e/specs/site-editor/navigation.spec.js b/test/e2e/specs/site-editor/navigation.spec.js index 3a51c7ad647499..a07e248952601f 100644 --- a/test/e2e/specs/site-editor/navigation.spec.js +++ b/test/e2e/specs/site-editor/navigation.spec.js @@ -48,6 +48,7 @@ test.describe( 'Site editor navigation', () => { } ); const editorCanvasButton = editorCanvasRegion.getByRole( 'button', { name: 'Edit', + exact: true, } ); await expect( editorCanvasButton ).toBeVisible(); diff --git a/test/e2e/specs/widgets/customizing-widgets.spec.js b/test/e2e/specs/widgets/customizing-widgets.spec.js index 03772d52a6b95d..47a83a04995c6d 100644 --- a/test/e2e/specs/widgets/customizing-widgets.spec.js +++ b/test/e2e/specs/widgets/customizing-widgets.spec.js @@ -635,7 +635,7 @@ test.describe( 'Widgets Customizer', () => { await page .locator( 'role=document[name="Block: Custom HTML"i]' ) .click(); - await page.getByRole( 'button', { name: 'Edit' } ).click(); + await page.getByRole( 'button', { name: 'Edit code' } ).click(); await expect( page.getByRole( 'dialog' ).getByRole( 'textbox' ) ).toHaveText( 'hello' ); From 0a507ad074790b622f2d6225eda8e45fb2470ef1 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 20:16:04 -0700 Subject: [PATCH 29/35] Keep the multi selection when Escape steps out Escape no longer collapses a selection of multiple blocks to the first one on its way out of the canvas. The selection survives the step out, and Enter on the stop returns to it. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/use-tab-nav.jsx | 10 ++++------ .../editor/various/multi-block-selection.spec.js | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 97451211bfd8a7..c7e752cff83948 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -196,12 +196,10 @@ export default function useTabNav() { ! event.metaKey && ! event.altKey ) { - // When multiple blocks are selected, Escape first collapses - // the selection to a single block, and when a content-only - // section is being edited, Escape first stops editing it. - // Those handlers live in the parent document and run after - // this one, so leave the key to them. - if ( hasMultiSelection() || getEditedContentOnlySection() ) { + // While a content-only section is being edited, Escape first + // stops editing it. That handler lives in the parent + // document and runs after this one, so leave the key to it. + if ( getEditedContentOnlySection() ) { return; } if ( focusCaptureBeforeRef.current ) { diff --git a/test/e2e/specs/editor/various/multi-block-selection.spec.js b/test/e2e/specs/editor/various/multi-block-selection.spec.js index dc2da459896324..53a08fe03a0ac2 100644 --- a/test/e2e/specs/editor/various/multi-block-selection.spec.js +++ b/test/e2e/specs/editor/various/multi-block-selection.spec.js @@ -257,7 +257,7 @@ test.describe( 'Multi-block selection (@firefox, @webkit)', () => { .toEqual( [ 3 ] ); } ); - test( 'should deselect with Escape', async ( { + test( 'should keep the selection when stepping out with Escape', async ( { page, editor, pageUtils, @@ -276,11 +276,23 @@ test.describe( 'Multi-block selection (@firefox, @webkit)', () => { .poll( multiBlockSelectionUtils.getSelectedFlatIndices ) .toEqual( [ 1, 2, 3 ] ); + // Escape steps out of the canvas onto its stop without removing the + // selection. await page.keyboard.press( 'Escape' ); + await expect( + page.getByRole( 'button', { name: 'Editor canvas' } ) + ).toBeFocused(); + await expect + .poll( multiBlockSelectionUtils.getSelectedFlatIndices ) + .toEqual( [ 1, 2, 3 ] ); + + // Enter returns to the selection. + await page.keyboard.press( 'Enter' ); + await expect .poll( multiBlockSelectionUtils.getSelectedFlatIndices ) - .toEqual( [ 1 ] ); + .toEqual( [ 1, 2, 3 ] ); } ); test( 'should present the editing host semantics during a cross-block selection', async ( { From bf21f8172ddb5313dccff23d5fcf19e069842f32 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 21:18:45 -0700 Subject: [PATCH 30/35] Keep the hint in view when the stop spans scrolling content On the widgets screen the stop spans the scrollable content, so a hint anchored to its top can sit far outside the view. Sticky positioning pins it to the top of the scrollport instead. The iframed editors do not scroll in the parent document, so the hint stays where it was. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/style.scss | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index 9b6222d4036f23..39d0985c72413c 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -20,13 +20,15 @@ } /* Looks like a tooltip. Shown at the top of the canvas while the stop is - focused. */ + focused. Sticky, because on the widgets screen the stop spans scrolling + content and the hint must stay in view; anywhere else there is nothing + to scroll and it stays at the top of the canvas. */ .block-editor-writing-flow__canvas-stop-hint { display: none; - position: absolute; + position: sticky; top: $grid-unit-20; - left: 50%; - transform: translateX(-50%); + margin-inline: auto; + width: fit-content; z-index: 2; background: $black; font-family: $default-font; From 9b0146dc777770ac5101a4222b72060d5c95a607 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 2 Sep 2026 22:01:51 -0700 Subject: [PATCH 31/35] Leave Escape and Tab alone during an IME composition During a composition those keys control the input method, so the canvas listeners must not claim them. The keycodes package has a wrapper for exactly this. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/use-tab-nav.jsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index c7e752cff83948..999e665d80adba 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -1,5 +1,5 @@ import { focus, isFormElement } from '@wordpress/dom'; -import { TAB } from '@wordpress/keycodes'; +import { TAB, withIgnoreIMEEvents } from '@wordpress/keycodes'; import { __ } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; import { useRefEffect, useMergeRefs, useInstanceId } from '@wordpress/compose'; @@ -290,10 +290,14 @@ export default function useTabNav() { } } - node.addEventListener( 'keydown', onKeyDown ); + // During an IME composition, Escape and Tab control the input + // method, not the editor. + const onKeyDownOutsideIME = withIgnoreIMEEvents( onKeyDown ); + + node.addEventListener( 'keydown', onKeyDownOutsideIME ); node.addEventListener( 'focusout', onFocusOut ); return () => { - node.removeEventListener( 'keydown', onKeyDown ); + node.removeEventListener( 'keydown', onKeyDownOutsideIME ); node.removeEventListener( 'focusout', onFocusOut ); }; }, [] ); From 2e26e5f55959062f0f7e8c10fef45cae55455646 Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 3 Sep 2026 08:47:18 -0700 Subject: [PATCH 32/35] Enter the canvas even when nothing in it is tabbable A locked canvas with nothing selected has no tabbable elements, so entering found nothing to focus and the key press died on the stop. Focus the canvas itself in that case, the same element a multi selection is entered on. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/use-tab-nav.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 999e665d80adba..1bfabf0cbfb005 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -97,6 +97,12 @@ export default function useTabNav() { const tabbables = focus.tabbable.find( containerRef.current ); if ( tabbables.length ) { tabbables[ 0 ].focus(); + } else { + // A locked canvas with nothing selected can be without + // tabbable elements entirely. Entering still enters: keys go + // to the canvas, and a block can be selected with the arrow + // keys or a click. + containerRef.current.focus(); } } } From 8035b1d6eda7f61092f3a20ade97c2352edef163 Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 3 Sep 2026 09:04:39 -0700 Subject: [PATCH 33/35] Correct the comment on the tabbable fallback Block wrappers are tabbable whether or not they are selected, and a content-only lock does not change that, so the earlier wording named the wrong scenario. The fallback covers any canvas that reports no tabbable elements at all. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../src/components/writing-flow/use-tab-nav.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 1bfabf0cbfb005..57db8330e57e89 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -98,10 +98,9 @@ export default function useTabNav() { if ( tabbables.length ) { tabbables[ 0 ].focus(); } else { - // A locked canvas with nothing selected can be without - // tabbable elements entirely. Entering still enters: keys go - // to the canvas, and a block can be selected with the arrow - // keys or a click. + // Nothing in the canvas is tabbable. Entering still enters: + // keys go to the canvas, and a block can be selected with + // the arrow keys or a click. containerRef.current.focus(); } } From 469614596d5d9d25562516f6aa38e427f4815a23 Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 3 Sep 2026 12:28:23 -0700 Subject: [PATCH 34/35] Engage the canvas as soon as focus arrives on the stop Tabbing in and out of the canvas behaves like trunk again. Escape is the only way to stand on the stop, where Tab regains traction and Enter returns to the caret. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- .../components/writing-flow/use-tab-nav.jsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx index 57db8330e57e89..fa3d3a36c31db1 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.jsx @@ -133,6 +133,18 @@ export default function useTabNav() { } } + // Focus arriving on the stop engages the canvas right away, like a + // text field that starts editing when focused. Only Escape parks + // focus on the stop, giving Tab traction again: from a parked stop, + // Tab moves to the interface around the canvas, not the content. + function onStopFocus() { + if ( noCaptureRef.current ) { + noCaptureRef.current = null; + return; + } + enterCanvas(); + } + const before = (
); - // Focus landing on the element after the canvas is forwarded to the - // stop before it, so there is one stop no matter which direction it is - // reached from. + // Focus landing on the element after the canvas enters it, the same + // as focus landing on the stop before it. function onFocusCapture() { - // Do not forward focus set by the tab handler below, which moves + // Do not act on focus set by the tab handler below, which moves // focus here so that the default behaviour (moving focus to the // next tabbable element) continues from this element. if ( noCaptureRef.current ) { noCaptureRef.current = null; return; } - focusCaptureBeforeRef.current?.focus(); + enterCanvas(); } const after = ( @@ -209,6 +221,9 @@ export default function useTabNav() { } if ( focusCaptureBeforeRef.current ) { event.preventDefault(); + // Park on the stop instead of engaging the canvas, + // which is what focus arriving on it normally does. + noCaptureRef.current = true; focusCaptureBeforeRef.current.focus(); } return; @@ -267,7 +282,7 @@ export default function useTabNav() { // Disable the focus forwarding on the focus capture element, so // it allows default behaviour (moving focus to the next tabbable // element). - noCaptureRef.current = ! isShift; + noCaptureRef.current = true; next.current.focus(); } From d7d9113a5196463f88024c69481d2845d4768eac Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 3 Sep 2026 12:34:01 -0700 Subject: [PATCH 35/35] Match the specs to the canvas engaging on focus The return journeys from the toolbar and from popovers go back to trunk shape, because focus arriving on the canvas enters it again. The stop tests now cover the direct entries and Escape being the only way to stand on the stop. Linters ran by hand; the pre-commit hook does not survive this environment. Co-Authored-By: Claude Fable 5 --- test/e2e/specs/editor/blocks/math.spec.js | 8 ++--- .../various/format-library/math.spec.js | 4 +-- .../various/keyboard-navigable-blocks.spec.js | 32 ++++++++++++------- .../editor/various/navigable-toolbar.spec.js | 22 +++---------- .../specs/editor/various/rich-text.spec.js | 7 +--- .../various/toolbar-roving-tabindex.spec.js | 3 -- 6 files changed, 29 insertions(+), 47 deletions(-) diff --git a/test/e2e/specs/editor/blocks/math.spec.js b/test/e2e/specs/editor/blocks/math.spec.js index 8b5f2d3aabe944..a21c2938b70523 100644 --- a/test/e2e/specs/editor/blocks/math.spec.js +++ b/test/e2e/specs/editor/blocks/math.spec.js @@ -25,11 +25,9 @@ test.describe( 'Math Block', () => { }, ] ); - // Can escape the popover. Focus lands on the canvas stop, and Enter - // moves it back to the block. + // Can escape the popover. await pageUtils.pressKeys( 'shift+Tab' ); await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'b' ); expect( await editor.getBlocks() ).toMatchObject( [ @@ -77,10 +75,8 @@ test.describe( 'Math Block', () => { // Fix syntax error. await page.keyboard.press( 'Backspace' ); - // Can delete the math block. Focus lands on the canvas stop, and - // Enter moves it back to the block. + // Can delete the math block. await pageUtils.pressKeys( 'shift+Tab' ); - await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Backspace' ); expect( await editor.getBlocks() ).toMatchObject( [ diff --git a/test/e2e/specs/editor/various/format-library/math.spec.js b/test/e2e/specs/editor/various/format-library/math.spec.js index 4e8d35bc359451..fef8bf541d8309 100644 --- a/test/e2e/specs/editor/various/format-library/math.spec.js +++ b/test/e2e/specs/editor/various/format-library/math.spec.js @@ -41,10 +41,8 @@ test.describe( 'Format Library - Math', () => { }, ] ); - // Test typing before. Focus lands on the canvas stop, and Enter - // moves it back to the text. + // Test typing before. await pageUtils.pressKeys( 'shift+Tab' ); - await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'ArrowLeft' ); await page.keyboard.type( 'a' ); 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 de215b0fdac113..9c3f6ce5e9fa3a 100644 --- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js +++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js @@ -30,9 +30,6 @@ test.describe( 'Order of block keyboard navigation', () => { await pageUtils.pressKeys( 'shift+Tab' ); await KeyboardNavigableBlocks.navigateThroughBlockToolbar(); await page.keyboard.press( 'Tab' ); - // The canvas is a single tab stop; Enter goes back to the block. - await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Editor canvas' ); - await page.keyboard.press( 'Enter' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Block: Paragraph' ); @@ -61,11 +58,7 @@ test.describe( 'Order of block keyboard navigation', () => { await page.keyboard.press( 'Tab' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Block' ); - // The canvas is a single tab stop; Enter goes back to the multi - // selection. await pageUtils.pressKeys( 'shift+Tab' ); - await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Editor canvas' ); - await page.keyboard.press( 'Enter' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Multiple selected blocks' ); @@ -165,10 +158,19 @@ test.describe( 'Canvas as a single tab stop', () => { ) .toBe( true ); - // Shift+Tab from the sidebar lands back on the stop, and another - // Shift+Tab skips the whole canvas backwards, onto the block toolbar - // floating over it. + // Shift+Tab from the sidebar enters the canvas directly: focus + // arriving on the stop engages it. await page.keyboard.press( 'Shift+Tab' ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ) + ).toBeFocused(); + await expect.poll( () => hasTextSelection( page ) ).toBe( true ); + + // From a parked stop, Shift+Tab reaches the block toolbar floating + // over the canvas. + await page.keyboard.press( 'Escape' ); await expect( stopBefore ).toBeFocused(); await page.keyboard.press( 'Shift+Tab' ); await expect @@ -183,8 +185,16 @@ test.describe( 'Canvas as a single tab stop', () => { ) .toBe( true ); - // Enter on the stop goes back to where focus left the canvas. + // Tab from the toolbar enters the canvas directly too. await page.keyboard.press( 'Tab' ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ) + ).toBeFocused(); + + // Enter on the stop goes back to where focus left the canvas. + await page.keyboard.press( 'Escape' ); await expect( stopBefore ).toBeFocused(); await page.keyboard.press( 'Enter' ); await expect( diff --git a/test/e2e/specs/editor/various/navigable-toolbar.spec.js b/test/e2e/specs/editor/various/navigable-toolbar.spec.js index e85d38a8c07023..c657a2b234f072 100644 --- a/test/e2e/specs/editor/various/navigable-toolbar.spec.js +++ b/test/e2e/specs/editor/various/navigable-toolbar.spec.js @@ -162,10 +162,6 @@ test.describe( 'Block Toolbar', () => { ).toBeFocused(); await pageUtils.pressKeys( 'Tab' ); - await expect - .poll( editor.getFocusOwnerLabel ) - .toBe( 'Editor canvas' ); - await pageUtils.pressKeys( 'Enter' ); await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Upload' ); } ); @@ -295,9 +291,7 @@ test.describe( 'Block Toolbar', () => { } ); await expect( blockToolbarParagraphButton ).toBeFocused(); await pageUtils.pressKeys( 'Tab' ); - // Focus lands on the canvas stop, and Enter moves it to the block. - await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Editor canvas' ); - await pageUtils.pressKeys( 'Enter' ); + // check focus is on the block await expect .poll( editor.getFocusOwnerLabel ) .toBe( 'Block: Paragraph' ); @@ -313,9 +307,7 @@ test.describe( 'Block Toolbar', () => { // check focus is within the block toolbar await expect( blockToolbarParagraphButton ).toBeFocused(); await pageUtils.pressKeys( 'Tab' ); - // Focus lands on the canvas stop, and Enter moves it to the block. - await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Editor canvas' ); - await pageUtils.pressKeys( 'Enter' ); + // check focus is on the block await expect .poll( editor.getFocusOwnerLabel ) .toBe( 'Block: Paragraph' ); @@ -330,9 +322,7 @@ test.describe( 'Block Toolbar', () => { await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Options' ); await pageUtils.pressKeys( 'Tab' ); - // Focus lands on the canvas stop, and Enter moves it to the block. - await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Editor canvas' ); - await pageUtils.pressKeys( 'Enter' ); + // check focus is on the block await expect .poll( editor.getFocusOwnerLabel ) .toBe( 'Block: Paragraph' ); @@ -353,9 +343,7 @@ test.describe( 'Block Toolbar', () => { // check focus is within the block toolbar await expect( blockToolbarParagraphButton ).toBeFocused(); await pageUtils.pressKeys( 'Tab' ); - // Focus lands on the canvas stop, and Enter moves it to the block. - await expect.poll( editor.getFocusOwnerLabel ).toBe( 'Editor canvas' ); - await pageUtils.pressKeys( 'Enter' ); + // check focus is on the block await expect .poll( editor.getFocusOwnerLabel ) .toBe( 'Block: Paragraph' ); @@ -407,8 +395,6 @@ test.describe( 'Block Toolbar', () => { // Check to make sure focus returns to the Move Up button roving index after all of this await pageUtils.pressKeys( 'Tab' ); - // Focus lands on the canvas stop, and Enter moves it to the block. - await pageUtils.pressKeys( 'Enter' ); // Hide the block toolbar await pageUtils.pressKeys( 'ArrowRight' ); // Check the block toolbar is hidden diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index 8ddf5c3a2ad203..22a833cae2b6fc 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -460,9 +460,6 @@ test.describe( 'RichText (@firefox, @webkit)', () => { await page.keyboard.type( '1' ); await page.keyboard.press( 'Tab' ); await pageUtils.pressKeys( 'shift+Tab' ); - // Focus lands on the canvas stop, and Enter moves it back to the - // text. - await page.keyboard.press( 'Enter' ); await pageUtils.pressKeys( 'primary+b' ); await page.keyboard.type( '2' ); await pageUtils.pressKeys( 'primary+b' ); @@ -676,10 +673,8 @@ test.describe( 'RichText (@firefox, @webkit)', () => { // Dismiss color picker popover. await page.keyboard.press( 'Escape' ); - // Navigate to the block. Tab moves focus to the canvas stop, and - // Enter moves it back to the text. + // Navigate to the block. await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Enter' ); // Copy the colored text. await pageUtils.pressKeys( 'primary+c' ); 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 670f151f64f7ad..8fc24b7bcdcd8d 100644 --- a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js +++ b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js @@ -156,9 +156,6 @@ class ToolbarRovingTabindexUtils { await this.page.keyboard.press( 'ArrowRight' ); await this.expectLabelToHaveFocus( 'Move up' ); await this.pageUtils.pressKeys( 'Tab' ); - // The canvas is a single tab stop; Enter goes back to the block. - await this.expectLabelToHaveFocus( 'Editor canvas' ); - await this.page.keyboard.press( 'Enter' ); await this.expectLabelToHaveFocus( currentBlockLabel ); await this.pageUtils.pressKeys( 'shift+Tab' ); await this.expectLabelToHaveFocus( 'Move up' );