0022. Escape leaves the editor, and Tab then continues in the page¶
Status: Accepted
Date: 2026-09-20
Context¶
Inside a code block, upstream's CodeKeyboardShortcutsExtension (node_modules/@blocknote/core/src/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts:27-42) binds Tab to insert two spaces, unconditionally, and never moves focus out of the editable. Shift+Tab is not in that keymap at all, so it falls through to the browser's own default handling of Tab inside a contenteditable region: focus moves backward, out of the editor. Amended 2026-09-20. 0011's Consequences section measured Tab and Shift+Tab alone, inside a code block; it recorded no forward exit and asked for one from neither this record nor any other. What that measurement did not cover was what Escape does, because nothing in the module reacted to Escape at the time, and no manual or automated test had pressed it inside a code block and then looked at document.activeElement.
Upstream's own OverrideEscape extension (node_modules/@blocknote/core/src/editor/managers/ExtensionManager/extensions.ts:84-99) already does this, in every block, not only a code block:
Escape: () => {
if (editor.getExtension(SuggestionMenu)?.shown()) {
// escape should close the suggestion menu, but not blur the editor
return false;
}
editor.blur();
return true;
},
the comment above it in the same file reading "make sure escape blurs editor, so that we can tab to other elements in the host page (accessibility)". Measured directly, in a mounted editor with the caret in a code block: after Escape, document.activeElement is document.body. Chrome's own sequential focus navigation then continues from that blurred position, but not symmetrically. A Tab pressed afterward, sent to whatever has focus, body included, moves to the next tabbable element on the page. Amended 2026-09-20. Not the route 0011 found missing, since 0011 asked for nothing; only the same measurement 0011 made, made again with Escape pressed first. A Shift+Tab pressed the same way does not reach the field before the editable; it returns focus to the editable itself, and it takes a second Shift+Tab, now an ordinary one out of a focused editable and unremarkable inside this record's own code-block scenario, to reach that previous field; in a nested block generally, that ordinary Shift+Tab would outdent it instead. This was measured, not predicted, in tests/src/FunctionalJavascript/WidgetBehaviourTest.php::testTabInsideCodeBlock, sending Tab and Shift+Tab through a helper that does not refocus the editable first, pressKeyWherever(): Tab lands on the form's next field in one press; Shift+Tab lands back on the editable in one press and on the previous field in two; a letter sent the same way reaches neither the editable nor the code block's text.
A release built the way CodeMirror and Monaco build one, consuming Tab in a handleKeyDown plugin, was tried first and does not survive this. A handleKeyDown plugin is bound to the editable's own DOM node and only ever sees an event whose target is that node or a descendant of it; once OverrideEscape has blurred it, document.activeElement is body, and a keydown sent there cannot reach that plugin, because DOM event bubbling only goes from a target up through its ancestors, and body is an ancestor of the editable, not a descendant of it. WidgetBehaviourTest's original assertions, built on pressKey(), did not catch this: pressKey() refocuses the editable first whenever document.activeElement is body or nothing, so every Tab or Shift+Tab that test sent after an Escape was delivered to the editable, not to wherever a real keyboard's next keystroke would actually land, which is the page. Once pressKeyWherever(), a sibling that sends a key to whatever has focus without that repair, was used instead, the earlier release's own armed flag turned out unreachable on the one path an author's keyboard actually takes: a real subsequent Tab never reached its handleKeyDown at all, because the event never reached the editable. It had one further, real defect on the one path that did reach it: pressing Shift+Tab after Escape, sent the same way, landed back on the editable itself, the same measured return this record's own announcement now names, rather than on the field before it; whatever the cause, the release's own armed flag, cleared only by a keydown its plugin actually saw, a mousedown, or a blur, was never cleared by that return, and a plain Tab pressed from there fired the release again, moving focus a second time, instead of indenting the way an ordinary Tab with no Escape before it does.
Decision¶
No Tab or Shift+Tab handling of this module's own, inside a code block or anywhere else. js/src/codeblock-tab.ts and its blocknoteCodeBlockTab extension are removed along with the tabbable direct dependency it needed (bun.lock had already resolved it at 6.5.0 through @floating-ui/react, so nothing else in the bundle changes). Tab inside a code block keeps inserting two spaces, and Shift+Tab keeps moving focus backward, exactly as before this record was first written. js/src/schema.tsx's indentLineWithTab is untouched, at upstream's default, because there was never a defect in what Tab itself does that this record needed to fix.
Escape leaving the editor is announced, in every block, in words that match what was measured. js/src/escape-announcement.ts's blocknoteEscapeAnnouncement extension's handleKeyDown plugin sets a flag on every Escape, cleared on the next microtask, and its own blur handler announces Drupal.t('Left the editor. Tab moves to the next field on the page; Shift+Tab returns to the editor.') politely through Drupal.announce(), once, only when that flag is still set. The message says "returns to the editor" for Shift+Tab, not "moves to the previous field", because that is what a Shift+Tab pressed right after this blur was measured to do; an author who presses it a second time reaches the previous field from a focused editable, the ordinary route 0011 already documents. Amended 2026-09-20. The message is Drupal.t('Left the editor.'). What Tab and Shift+Tab do from there was measured in Chrome alone, and even in Chrome is not always "the next field on the page" or "the editor" in those exact words, so the announcement no longer describes either; it says only that Escape left the editor, the one part every measurement agrees on. Because OverrideEscape's own blur, when it happens, happens synchronously within the same handling of the same Escape keydown, the flag is still set at that point and the announcement fires; an Escape that instead closes a suggestion menu, where OverrideEscape itself returns false and never calls blur(), fires no blur event at all, so nothing is announced for it. No runsBefore is needed: this extension never has to run before or after anything else, only to see the same Escape keydown and the blur that may or may not follow it. Its key is taken back out of options.disableExtensions after every registered alter has run, the same as the module's other four such extensions, listed in js/src/editor.tsx.
The browser test sends keys to whatever has focus. WidgetBehaviourTest::testTabInsideCodeBlock presses Escape, asserts document.activeElement === document.body and that the announcement's live region carries the message, then sends Tab, Shift+Tab and a letter through pressKeyWherever() rather than pressKey(), which would otherwise refocus the editable and hide exactly the thing this test exists to prove: that an author's own next keystroke, delivered nowhere but the page, still lands correctly.
Alternatives considered¶
The handleKeyDown-based release, as this record first tried it. Rejected. Unreachable on the real keyboard path once OverrideEscape's blur is accounted for, and, on the one path that did reach it, capable of firing on a Tab that should have indented, because its armed flag could be left set by a Shift+Tab its own plugin never saw. Building an explicit, deterministic focus move was meant to avoid depending on the browser's own post-blur tab order; it ended up depending on it anyway, since that browser behavior is what carries a real keystroke to the editable's own listener or away from it in the first place, and having also gotten it wrong on the one path it did reach did not leave anything to keep.
indentLineWithTab: false, so Tab moves focus on its own. Rejected, unchanged from this record's first draft. That option removes indentation entirely; every Tab, not only one after Escape, would move focus instead of inserting spaces, taking away the one thing a code block's own Tab is for. It was never needed: whether runsBefore lifts a module extension above a block spec extension, the question this record set out to answer, turned out not to be the operative one at all, since the design that needed lifting above anything was itself withdrawn.
A setting for whether Escape's departure is announced. Rejected. The same reasoning 0011 and 0014 already give for other keyboard routes applies here: this is a WCAG-shaped choice, not a stylistic one, and a site administrator is not in a position to choose it well.
Consequences¶
Tab inside a code block always inserts two spaces, in every state, with no Escape able to change that; this is unchanged from before this record and from upstream's own behavior.
Escape is the exit from every block, not only a code block, because OverrideEscape blurs the editable regardless of what block the caret is in; the announcement follows that same scope. An author who presses Escape in a paragraph, expecting nothing in particular to happen, is now told that it left the editor.
The announcement fires once per Escape that actually blurs, whether or not the author goes on to press Tab or Shift+Tab afterward, or does something else entirely once the page has focus back.
Leaving by Shift+Tab takes one more keystroke than leaving by Tab. An author who presses Shift+Tab right after Escape is returned to the editable, not moved past it; a second Shift+Tab is what reaches the field before it. The announcement says so, rather than promising the field in one press.
Chrome's continuation of sequential focus navigation from a blurred element with no explicit successor is what this record relies on for the forward case and is what testTabInsideCodeBlock measures directly; the backward case's return to the editable on the first press is measured the same way. Firefox's and Safari's handling of the same starting point was not measured, because this module's own browser tests run against Chrome, and is not claimed here either way.
This record was written once already, against a design that turned out to rest on the false premise that a handleKeyDown plugin could reach a Tab pressed after Escape had already blurred the editable; it is replaced here, in the same unpublished change, rather than superseded, because it had not yet shipped.
References¶
- WCAG 2.1: 2.1.2 No Keyboard Trap, 4.1.3 Status Messages.
- BlockNote 0.54.2:
@blocknote/coresrc/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts,src/blocks/Code/block.ts,src/editor/managers/ExtensionManager/extensions.ts(84-99,OverrideEscape). - Drupal core 11.4.6:
core/misc/announce.js. - This module:
js/src/escape-announcement.ts,js/src/editor.tsx. - Tests:
js/src/__tests__/escape-announcement.test.ts,tests/src/FunctionalJavascript/WidgetBehaviourTest.php::testTabInsideCodeBlock,BlockNoteEditorTestBase::pressKeyWherever(). - 0011, 0014.