Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
96ec6e5
Make the editor canvas a single tab stop
ellatrix Sep 2, 2026
055345a
Add the changelog entry
ellatrix Sep 2, 2026
50161af
Show the hint as a real tooltip
ellatrix Sep 2, 2026
1d59497
Show the hint in one place, whichever stop is focused
ellatrix Sep 2, 2026
a7ebcca
Drive the canvas focus ring with a class, not :has()
ellatrix Sep 2, 2026
c634ccc
Draw the canvas focus ring as an overlay
ellatrix Sep 2, 2026
4c1c7cd
Lift the automatic-change undo out of rich text, drop the microtask
ellatrix Sep 2, 2026
25e9ab9
Render the hint as a plain badge on the stop
ellatrix Sep 2, 2026
8344055
Drop the changelog entry until this is headed to merge
ellatrix Sep 2, 2026
c05de37
One wrapper, one stop
ellatrix Sep 2, 2026
2a5f2c3
Focus the canvas window before the element in it
ellatrix Sep 2, 2026
97fbbad
Keep the frame's document out of the page's tab order
ellatrix Sep 2, 2026
b22fe93
Restore the before and after elements; the before one is the stop
ellatrix Sep 2, 2026
abcc1d1
Move the automatic change undo into its own writing flow file
ellatrix Sep 2, 2026
d52f65f
Span the stop and the redirect like trunk spans the capture elements
ellatrix Sep 2, 2026
598f014
Keep trunk names and drop dead style rules
ellatrix Sep 2, 2026
6c9a43f
Suppress the native outline in the shared style
ellatrix Sep 2, 2026
df1a857
Drop the arrow keys from the stop and restore the trunk branching
ellatrix Sep 2, 2026
904a01c
Say what the scale container anchors: the stop, not the ring
ellatrix Sep 2, 2026
5f258b4
Shape the moved undo listener like the original
ellatrix Sep 2, 2026
3063488
Let the resizable editor container anchor the stop
ellatrix Sep 2, 2026
2d5eb59
Order the undo before the tab-nav listeners instead of capturing
ellatrix Sep 2, 2026
7f180da
Keep the after element on its trunk lines
ellatrix Sep 2, 2026
b90966a
Assert the undo priority in the existing list test
ellatrix Sep 2, 2026
66c057f
Fold the tab hops into the Escape journey
ellatrix Sep 3, 2026
5ceda7b
House the canvas stop tests with the keyboard navigation suite
ellatrix Sep 3, 2026
bca5714
Enter the canvas on click too, and say things more plainly
ellatrix Sep 3, 2026
23a12e3
Let Escape claimants in the parent document run before the step out
ellatrix Sep 3, 2026
0a507ad
Keep the multi selection when Escape steps out
ellatrix Sep 3, 2026
bf21f81
Keep the hint in view when the stop spans scrolling content
ellatrix Sep 3, 2026
9b0146d
Leave Escape and Tab alone during an IME composition
ellatrix Sep 3, 2026
2e26e5f
Enter the canvas even when nothing in it is tabbable
ellatrix Sep 3, 2026
8035b1d
Correct the comment on the tabbable fallback
ellatrix Sep 3, 2026
4696145
Engage the canvas as soon as focus arrives on the stop
ellatrix Sep 3, 2026
d7d9113
Match the specs to the canvas engaging on focus
ellatrix Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,7 +18,6 @@ const allEventListeners = [
removeBrowserShortcuts,
shortcuts,
inputEvents,
undoAutomaticChange,
pasteHandler,
_delete,
enter,
Expand Down

This file was deleted.

5 changes: 5 additions & 0 deletions packages/block-editor/src/components/writing-flow/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +29,10 @@ export function useWritingFlow() {
return [
before,
useMergeRefs( [
// 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(),
Expand Down
48 changes: 48 additions & 0 deletions packages/block-editor/src/components/writing-flow/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@use "@wordpress/base-styles/colors" as *;
@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 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;
inset: 0;
z-index: 1;
pointer-events: none;
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));
}

/* Looks like a tooltip. Shown at the top of the canvas while the stop is
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: sticky;
top: $grid-unit-20;
margin-inline: auto;
width: fit-content;
z-index: 2;
background: $black;
font-family: $default-font;
border-radius: $radius-medium;
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;
}
170 changes: 141 additions & 29 deletions packages/block-editor/src/components/writing-flow/use-tab-nav.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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 } 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';
Expand All @@ -17,12 +18,20 @@ const PREVENT_SCROLL_ON_FOCUS = {
position: 'absolute',
inset: 0,
pointerEvents: 'none',
outline: 'none',
};

// Keys that move focus from the canvas stop into the canvas.
const ENTRY_KEYS = [ 'Enter', ' ', 'F2', 'Escape' ];

export default function useTabNav() {
const containerRef = /** @type {typeof useRef<HTMLElement>} */ ( useRef )();
const focusCaptureBeforeRef = useRef();
const focusCaptureAfterRef = useRef();
const hintId = useInstanceId(
useTabNav,
'block-editor-writing-flow__canvas-stop-hint'
);

const {
hasMultiSelection,
Expand All @@ -31,6 +40,7 @@ export default function useTabNav() {
getBlockOrder,
getLastFocus,
getSectionRootClientId,
getEditedContentOnlySection,
isZoomOut,
} = unlock( useSelect( blockEditorStore ) );
const { setLastFocus } = unlock( useDispatch( blockEditorStore ) );
Expand All @@ -39,16 +49,17 @@ export default function useTabNav() {
// 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;
// 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() {
// 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();

// Do not capture incoming focus if set by us in WritingFlow.
if ( noCaptureRef.current ) {
noCaptureRef.current = null;
} else if ( hasMultiSelection() ) {
if ( hasMultiSelection() ) {
containerRef.current.focus();
} else if ( getSelectedBlockClientId() ) {
if ( getLastFocus()?.current ) {
Expand Down Expand Up @@ -80,32 +91,101 @@ export default function useTabNav() {
.focus();
} else {
// If we don't have any section root, focus the canvas.
canvasElement.focus();
containerRef.current.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();
tabbables[ 0 ].focus();
} else {
// 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();
}
}
}

function onStopKeyDown( event ) {
if (
event.defaultPrevented ||
event.ctrlKey ||
event.metaKey ||
event.altKey
) {
return;
}

const { key } = event;

if ( ! event.shiftKey && ENTRY_KEYS.includes( key ) ) {
event.preventDefault();
enterCanvas();
return;
}

// 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();
}
}

// 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 = (
<div
ref={ focusCaptureBeforeRef }
tabIndex="0"
onFocus={ onFocusCapture }
role="button"
aria-label={ __( 'Editor canvas' ) }
aria-describedby={ hintId }
className="block-editor-writing-flow__canvas-stop"
style={ PREVENT_SCROLL_ON_FOCUS }
/>
onFocus={ onStopFocus }
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 hint is also the button's description for assistive
technology. Content hidden with `display: none` still
works for `aria-describedby`. */ }
<div
className="block-editor-writing-flow__canvas-stop-hint"
id={ hintId }
>
{ __( 'Press Enter to edit the document' ) }
</div>
</div>
);

// Focus landing on the element after the canvas enters it, the same
// as focus landing on the stop before it.
function onFocusCapture() {
// 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;
}
enterCanvas();
}

const after = (
<div
ref={ focusCaptureAfterRef }
Expand All @@ -121,12 +201,39 @@ export default function useTabNav() {
return;
}

// 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
) {
// 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 ) {
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;
}

// 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;
}
Expand Down Expand Up @@ -169,11 +276,12 @@ 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).
// Disable the focus forwarding on the focus capture element, so
// it allows default behaviour (moving focus to the next tabbable
// element).
noCaptureRef.current = true;

next.current.focus();
Expand Down Expand Up @@ -202,10 +310,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 );
};
}, [] );
Expand Down
Loading
Loading