0011. The editor is named and described by its field, and every function has a keyboard route¶
Status: Accepted; the media block's own controls in the Decision section are superseded by 0019 (2026-09-19)
Date: 2026-09-19
Context¶
The module commits to WCAG 2.1 AA for the widget and for the public output. The probe recorded in 0003 and a later review of the editor inside Claro and Gin found where the editor fell short.
The contenteditable had no accessible name, because the field label's for points at the textarea the editor hides. It had no description, required state or error state either.
The side menu renders only while the pointer hovers a block, so its Add block and Open block menu buttons could not be reached from the keyboard, and with them block colors, deleting a block, and the table's row, column and header operations. Inside a link, Tab nests the block and never reaches the link toolbar.
The notices beside the editor, which name what the format stripped, the blocks it carries and the files that are missing, were live regions that are already filled when they are added to the page, which screen readers do not reliably announce, and none of them was tied to the editor. The stripped-content notice named removals in the client's machine vocabulary, such as textColor:red and link:mailto, and the carried-blocks notice and the save refusal named block types by machine name.
BlockNote's palette text colors missed 4.5:1. Six of nine failed in the dark scheme against the editor background #1f1f1f: red 4.22, brown 3.24, green 4.45, blue 4.09, purple 3.29 and pink 3.60. Gray, red, orange and yellow failed in the light scheme against white, and those four are also the defaults of the public stylesheet.
The public checklist rendered an unlabeled checkbox beside its text, and docs/rendering.md left open whether an image's alt text is its name.
Several platform facts bound the answer. BlockNote fixes domAttributes when the editor is created, so attributes on the editable cannot follow a notice that appears later. Core's Drupal.announce() speaks a message through one shared live region, politely or assertively. Claro and Gin both draw a visible focus ring on the contenteditable, where CKEditor 5 suppresses the theme's ring and draws its own. CKEditor 5 uses Alt+F10 to move focus from the editing area to its toolbar, a shortcut Drupal authors already meet. TableHandlesExtension resolves its add and remove calls against the table under the pointer, so they cannot be driven from the caret, while the prosemirror-tables commands act on the selection. CKEditor 5's to-do list renders each checkbox inside a label with the item's text.
Decision¶
The editable takes its name, description and state from the field. editorAttributes() in js/src/a11y.ts reads the form around the textarea and gives the contenteditable role="textbox" and aria-multiline, and aria-labelledby pointing at the field's label, which is given an id when it has none; without a label it falls back to the textarea's aria-label or title. aria-describedby names the field's description and its inline error message, aria-required is set when the textarea is required, and aria-invalid when the field is in error. These are set as domAttributes.editor before the options alters run. aria-readonly follows the textarea's disabled state.
A notice present when the editor is created is a description; a notice that changes later is announced. The stripped, carried and missing-files notices carry no live-region role and have the ids <editor id>-stripped, <editor id>-carried and <editor id>-missing, which the editable's aria-describedby names when they are present at creation. The limit notice keeps its visible text and is announced politely through Drupal.announce() when the document crosses a new limit, not on every measurement. The upload error keeps its visible text and is announced assertively when it changes. Amended 2026-09-14. The report is taken down before each file is sent, so a second file refused for the same reason is a change and is announced again. A refused save stays the field's own form error, which the editable already names through aria-invalid and its description. Amended 2026-11-02. The limit notice and the missing-files notice are rendered inside one element, <editor id>-status, which the editable's aria-describedby names from creation, after the attach notices, whatever it holds; the missing-files notice no longer has an id of its own. The attributes are still fixed at creation. What changes is the content of an element they name, which is read whenever the description is computed, so either notice describes the editable whenever it is shown, one that appears after creation included. Writing aria-describedby again after creation was not taken: BlockNote hands domAttributes.editor to ProseMirror as its attributes prop, a plugin's own attributes cannot add to an attribute that prop already sets, and ProseMirror writes every attribute from that prop again whenever it rebuilds its document view (computeDocDeco() and docViewDesc() in prosemirror-view 1.42.5, read from the source). A notice that appears after creation is also announced politely, because a description is read when focus arrives and not when it changes: the missing-files notice when the count of files not found grows after creation, such as a file found missing while blocks render, and the limit notice for a document already over a limit when the editor attaches, which is measured only once the editor exists. An attach follows a format switch or an AJAX rebuild as well as a page load, with the author on the page. A crossed limit is announced as before, and a limit announced at attach is not announced again while the document stays over it. The rejected alternative below, announcing as well as describing, still holds for a notice present at creation; a late notice is both, the announcement for an author already in the editor and the description for one who arrives after it. The upload error stays outside the element: it reports an upload rather than the document, the next upload clears it, and it is announced assertively and not described. Amended 2026-11-02. The limit notice of a document already over a limit when the editor attaches is announced only when focus is already in the field, the text format element the textarea belongs to, such as on its format select after a format switch. On a page load, and after an AJAX rebuild that took focus away with the element it replaced, it is described and not announced, as the notices present at creation are: announced there, it was spoken on every page that opened such a document, from no field the author was in. A limit crossed while editing is announced as before.
Notices and refusals name things in words. The editor plugin sends editorSettings.blockLabels, the label of every block plugin, and the carried-blocks notice and the constraint's refusal name a block by its label, or by its type when no plugin claims it. The stripped-content notice names a style by its toolbar tooltip, a color as "red text" or "red background" from the color picker's names, and a link as "links to mailto addresses", joined in the page's language. data-blocknote-stripped keeps the machine names.
Alt+F10 opens a block menu with every side-menu and table-handle function. js/src/blockmenu.tsx renders a WAI-ARIA menu, positioned with @floating-ui/react against the block that holds the caret and rendered into the editor's portal element. Amended 2026-10-31. BlockNote 0.55.0 removed editor.portalElement; the menu is rendered into the element @blocknote/react's usePortalElement() returns, the element wrapping the editable unless a surface sets another, which is where upstream's own menus are rendered too. For the block it offers Move up, Move down, Indent, Outdent, a Checked checkbox on a checklist item, a Colors submenu of the format's palette, and Delete. Inside a table, when the table handles are on, it adds rows and columns added and deleted through prosemirror-tables commands run with editor.exec(), Merge cells through TableHandlesExtension with no arguments, and a Cell colors submenu for the selected cells; Split cell is offered the same way whatever the table handles surface is set to, as the repair route for a cell a document already holds merged. Amended 2026-09-16. Header row and Header column checkboxes are offered for a table's block too, whatever the table handles surface is set to, next to Delete rather than in that table-handle group, the way upstream's own drag handle menu offers them regardless. Amended 2026-09-20. Header row and Header column checkboxes sit at the head of the table-handle group instead of next to Delete, gated on the side menu or the table handles surface, whichever is on, not on the table handles surface alone: with the side menu on and the table handles off, the side menu's own drag handle menu is still the pointer route to them, and with the table handles on and the side menu off, upstream's own row and column handle menus are. With both surfaces off there is no pointer route to them either, so nothing is offered and nothing is lost. Unlike the 09-16 amendment above, the group itself is built only with the caret inside a table, so a table block reached by node selection alone no longer offers them either. An item is offered only when its command can run. The up and down arrows, Home and End move between items; Enter or Space activates one and returns focus to the caret; the right arrow opens a submenu, and the left arrow or Escape closes it; Escape closes the menu and returns focus to the caret; and Tab or a click outside closes it without moving the caret. Amended 2026-09-15. Shift+Tab closes it too, handled by the menu itself and returning focus to the editable, because the focus guard before the portal moves focus backwards without closing anything, and the caret is where backward navigation from that position leads anyway. Amended 2026-09-16. A block registered with blockMenuGroups adds its own groups ahead of these, which is the keyboard route for whatever it puts on the formatting toolbar; 0014 records the contract. Amended 2026-10-31. An open menu follows what it offers: it is built again when the document or the selection changes, and when a block reports a change to what its own groups read outside the document (0014), so an item that becomes available, or a checked state that changes, shows while the menu is open. Focus stays on the item that had it, or moves to the item now at that position when that item goes, so a rebuild never drops it to the page, and the menu closes, returning focus to the caret, once the block it was opened for no longer holds the caret. Amended 2026-11-01. The menu takes only its position from the block's element, through floating-ui's position reference, and is not given the element itself: given an element, floating-ui's focus manager inserts a hidden element after it, which here landed inside the block's own node view, and tiptap throws when it destroys a node view whose first element is no longer its wrapper, so detaching the editor with the menu open on a media block failed. A press on the block the menu was opened for is now a press outside the menu like any other and closes it the same way; before, floating-ui counted it as a press on the reference and left the menu open beside the caret.
The side menu is always the module's own, its buttons out of the tab order, and its Add block button never converts a block whose caption is still empty. Amended 2026-09-16. BlockNote hides the side menu in a capture-phase keydown on the very Tab that would move into it (SideMenu.ts onKeyDown), and its closing popover renders a static copy of its markup for 250 ms while it fades (GenericPopover.tsx), so Tab used to focus that copy and its removal dropped focus to the page. js/src/menus.tsx supplies the side menu unconditionally now, whether or not the format trims the palette, wrapped in a layout effect that sets tabindex="-1" on Add block and Open block menu early enough for the popover's copy to carry it, so Tab passes over the menu and the copy alike. Nothing loses its only route: Add block opens the slash menu "/" opens, and the drag handle menu's items are all in the block menu Alt+F10 opens. The same component's Add block button also stops treating a block registered with inlineContent: 'caption' as empty: such a block's caption starts empty, content: [], the shape upstream's own check cannot tell from a block with nothing to lose, and choosing a slash-menu item there used to reach updateBlock() and replace the block itself, dropping whatever it held. The button now takes upstream's own branch for a block that is not empty, inserting a new block after it and opening the menu there. Amended 2026-09-20. A pointer choice inside the drag handle menu that closes it, such as Delete or a Colors swatch, drops focus to document.body: the Mantine menu is built with returnFocus={false}, and neither item calls anything that refocuses. SideMenuOutOfTabOrder now carries a focusout listener of its own: on the next frame, when the element that lost focus is no longer in the document and focus fell all the way to body, the editor is refocused. Measured directly, this is where focus is actually lost; the table row and column handle menus and the link toolbar's Remove link do not lose it, so the listener is scoped to this component rather than installed once for every surface. Amended 2026-11-03. A new, empty callout gave a pointer no way to its tone or title: BlockNote shows the formatting toolbar only for selected text, and the drag handle menu held only Delete and Colors. The drag handle menu now opens with the groups the block menu offers ahead of its own, which registeredMenuGroups() in js/src/blockmenu.tsx builds for both from the same registrations: the block's own blockMenuGroups, the Style group where the format lists styles for its type, and, for a child of a block registered with childTypes, such as a gallery's item, that block's groups after the child's. A block that registers groups for the keyboard therefore reaches a pointer with no code of its own. Upstream's Delete, Colors, Header row and Header column follow, after a separator, in a group of their own, with the Colors item swapped for the format's palette where the format trims it, so js/src/menus.tsx supplies the drag handle menu for every format, not only for one that trims the palette. Choosing an item runs the entry's own run, the handler the block menu runs, and then gives focus to the editor, as the block menu does, so an entry that opens a form, such as the callout's Title or the media block's Alternative text, opens it the same way. Each group is an element with role="group" named by its label, which is shown above its items and hidden from assistive technology, a separator stands between two groups, and an entry with a sub-menu, such as the media block's View mode or the gallery's Columns, opens it beside the menu as upstream's Colors does. Mantine's menu item sets role="menuitem" after the props it is given, so a radio or checkbox entry gets its role on its element once rendered, as the Style select's items do (js/src/menu-item-role.ts), and carries aria-checked. The groups are built when the menu opens, outside React's render, and again while it is open whenever the document changes or the block reports a change through blockMenuSubscribe, with the block menu's limit of three further builds in a row. The keys are Mantine's own: the arrow keys move between the items and wrap at either end, Home and End go to the first and the last, and Enter chooses one. CalloutEditingTest, MediaLibraryInsertionTest and MediaGalleryEditingTest open the menu with a real pointer and choose from it, and CalloutEditingTest reads the group's name and each item's role and checked state from the browser's accessibility tree; js/src/__tests__/menus.test.tsx holds what the menu offers.
Tab from inside a link reaches the link toolbar, and Escape returns to the caret. 0010 records the plugin. Escape needs no code of the module's own, because LinkToolbarController's dismiss handling already focuses the editor.
Palette text colors meet 4.5:1 in both schemes. js/src/theme-isolation.css overrides --bn-colors-highlights-<name>-text under .blocknote-editor .bn-root[data-color-scheme="light"] and [data-color-scheme="dark"], which reaches the menus as well as the document. css/blocknote.content.css uses the same light values as its defaults, so the editor and the public page agree. Each failing color moves by the smallest change of HSL lightness, at its own hue and saturation, that reaches 4.5:1. Light: gray #787673, red #de3434, orange #b5600b and yellow #947201. Dark: brown #a87c68, red #ed4c4c, green #6c8c88, blue #0f8fc7, purple #9a70de and pink #e447a4. BlockNoteView keeps theme as the name of the scheme. Amended 2026-09-15. The link form's refusal message is measured the same way and is the module's own colour, --blocknote-refusal-text, declared per scheme in the same stylesheet: #c92a2a on the light popover, 5.46:1, and #fa5252 on the dark one, 5.02:1. Mantine's error colour, which it carried before, is 3.28:1 light and 3.65:1 dark.
The drop cursor is drawn in the scheme's own color. Amended 2026-09-16. Light keeps upstream's default, #ddeeff. Dark is #0b6e99, the kit's own blue highlight background of that scheme (defaultColors.ts), 2.9:1 against the editor's #1f1f1f. Upstream's own light value reads about 13.9:1 against that same background, the glare that motivated giving dark its own color; #0b6e99 is a step down from that, chosen for the scheme rather than for contrast. createEditor() fixes the color once, from the same reading of the page's color-scheme the view's theme already follows.
Block animations follow the reader's motion preference. Amended 2026-09-16. createEditor() reads matchMedia('(prefers-reduced-motion: reduce)') once, at creation, and sets animations: false when it matches. There is no setting, and a browser or test environment offering no matchMedia keeps upstream's default of animating.
The admin theme's focus ring stays. Claro and Gin draw the ring on the contenteditable, it meets 2.4.7, and a browser test holds it. Amended 2026-09-14. Upstream's stylesheet sets outline: none on .bn-editor, so a theme that draws no focus rule reaching the editable leaves it with no indicator at all, which fails 2.4.7 outside the two themes this record was written against. js/src/theme-isolation.css draws one there, as :where(.blocknote-editor .bn-editor):focus-visible: one pseudo-class and nothing else outside :where(), which Claro's .page-wrapper *:focus and Gin's :where(*) .page-wrapper *:focus, each a class and a pseudo-class, and Gin's :is(#extra-specificity-hack, [data-drupal-admin-styles]) .page-wrapper *:focus, which adds an identifier, all outrank. Measured under Claro, the focused editable computes the same outline and box-shadow with the rule as without it. The alternative below is unchanged by this: what stays rejected is an editor-owned ring that suppresses the theme's, which is what CKEditor 5 draws, and not a fallback that loses to every theme rule. Amended 2026-09-26. The link form's two text inputs are a deliberate, narrow exception to this paragraph's own rule, not the rejected alternative below: their theme focus ring is suppressed, and the caret, together with the row's own background tint on the focused field, is the indicator instead, because Understanding 2.4.7 lists the caret as an accepted indicator for a text field and because the popover is the editor's own chrome rather than an ordinary field on the surrounding page. The checkbox and the Save button beside the two inputs are not part of this exception and keep the theme's ring, like every other control on the form. 0032 has the checkbox and the Save button this paragraph exempts. Amended 2026-09-26. The exception covers the text inputs of the module's own three link forms and nothing else: the URL field in each, and the link text field, which only the link toolbar's Edit link form shows, since the formatting toolbar's form and a content link's Edit form show the URL field alone. The rules in js/src/links.css that take the ring off are keyed on blocknote-link-form-popover, a class those three popovers carry beside the kit's shared bn-form-popover, so the caption and rename fields that upstream's file buttons draw in the same kit popover keep the theme's ring. Amended 2026-11-02. Default Admin, which replaces Claro in Drupal 11.5, draws its ring as an outline and not as a box-shadow: its .page-wrapper *:focus gives the editable a 4-pixel solid outline in its focus color, and sets no offset. The fallback set outline-offset: -2px beside its outline, and since no theme rule sets an offset, nothing outranked it: under Default Admin the theme's ring was drawn 2 pixels into the box. The fallback now sets the outline alone, so the ring Olivero gets from it is drawn at the box's edge rather than inside it, and the focus ring tests in Claro, Default Admin and Gin compare the offset with the ring the theme draws on an element of its own as well. In the link form under Default Admin, the focused checkbox carries that same outline, which LinkToolbarTest compares with it.
A checklist checkbox is labeled by its item, and an image's alt text is its name. A checklist item renders <li><label class="blocknote-check-list-item__label"><input class="blocknote-check-list-item__checkbox" type="checkbox" disabled [checked]/><span class="blocknote-check-list-item__content">...</span></label>{{ children }}</li>, the shape of CKEditor 5's to-do list. An image's alt is the block's name, which an author edits with Rename image, and an image with no name renders alt="".
The media block's and the callout's controls have names and keyboard routes. The media block's preview is a group named by the media item's label, its buttons are named by their text, Select media and Change media, and its view mode select is named "Media view mode". The callout's controls are named "Callout tone" and "Callout title". From the end of the text block before one of these blocks, Tab reaches each control in turn and Shift+Tab returns to the editable, and the arrow keys change a select without moving focus. Amended 2026-09-19. Since 0019, a chosen media block draws no control of its own; the empty state's Select media button is the one control it still draws, and the media block's functions are reached through the formatting toolbar and the Alt+F10 Media group, where the view mode trigger's accessible name states the current mode, for example "Media view mode: Wide". Amended 2026-09-20. The group's content is inert: whatever the view mode renders, a player, a link or a form control, is not focusable, is ignored by assistive technology and takes no pointer event, so Tab never stops inside a preview and no embedded control competes with the block's own functions; the group's name is still announced. See 0019's amendment of the same date. Amended 2026-11-02. That the group's name is still announced is inferred, not checked: the group carries the name and sits outside the inert wrapper, one level above it, but whether a screen reader announces a group whose whole content is inert has not been tried with one, and an agent cannot drive one. The maintainer checks it with VoiceOver and Safari. NVDA with Firefox or Chrome stays unchecked until someone on Windows tries it.
WCAG 2.1 AA is the baseline, and 2.5.8 and 2.4.11 are measured as best effort. In Claro, at the browser tests' window size, the formatting toolbar's buttons are 30 by 30 pixels and its block type select 122 by 30. Slash menu items are 315 by 52. The side menu's Add block and Open block menu buttons are exactly 24 by 24. The link toolbar's Edit link button is 71 by 30, and its Open in new tab and Remove link buttons 30 by 30. Every block menu item is at least 24 pixels tall, 32 by its style, in a menu at least 200 pixels wide. For 2.4.11, the formatting toolbar sits above the selection and covers neither the selection nor the Title field; a focused toolbar button was not measured. Amended 2026-09-26. The link toolbar carries a fourth button: Edit link, Open link, Open in a new tab, Remove link, in that order, where the format permits a new-tab choice (0032). Sizes are re-measured in LinkToolbarTest once the toggle is built; the three sizes above stay accurate for a format that does not permit the new-tab choice, where the toolbar keeps its original three buttons. Amended 2026-09-26. Re-measured in LinkToolbarTest::testTheLinkToolbarIsLaidOutInOneRow with the toggle built: Edit link is 71 by 30, Open link 30 by 30, Open in a new tab 125 by 30 and Remove link 30 by 30. Amended 2026-09-26. The fourth button is gone again. The new-tab choice moves into the link form as a checkbox beside a Save button, 0032's amendment of the same date records, and the link toolbar returns to three buttons, Edit link, Open link, Remove link, the content-link toolbar to its own three, title, Edit link, Open link, Unlink. Sizes are re-measured in LinkToolbarTest once built. Amended 2026-09-26. Re-measured in LinkToolbarTest::testTheLinkToolbarIsLaidOutInOneRow with the toolbar back to three buttons: Edit link is 71 by 30, Open link 30 by 30 and Remove link 30 by 30, the same three sizes this paragraph opened with, since the fourth button's own size is what changed and is now gone. LinkToolbarTest::testTheLinkFormNamesItsFieldsAndShowsNoThemeRing measures the form itself: the URL field's programmatic label is present but at most 1 by 1 pixel, its focused input computes outline-style: none and box-shadow: none, and the checkbox reached by a real Tab computes a non-none box-shadow, the theme's own ring. Amended 2026-09-26. The test reads the label's width alone, at most 1 pixel; its height of 1 pixel is set by js/src/links.css and not measured. Amended 2026-11-02. Measured again in Default Admin, where the browser tests run, at the same window size, 1024 by 768: the formatting toolbar's buttons are 30 by 30 and its block type select 127 by 30 showing Heading 2, slash menu items 329 by 52, the side menu's two buttons 24 by 24, and every block menu item 32 pixels tall in a menu 200 wide. The link toolbar's Edit link is 73 by 30, wider than in Claro because Default Admin loads Inter, the first font of the kit's stack, and Open link and Remove link are 30 by 30. LinkToolbarTest now runs in Default Admin and holds those three sizes. 2.4.11 was not measured again.
Alternatives considered¶
An editor-owned focus ring, as CKEditor 5 draws. Rejected. The admin theme's ring already meets 2.4.7 and matches every other field on the form.
Documenting the contrast failures. Rejected. The failing colors are BlockNote's defaults as the module ships them, and a stylesheet fixes them.
A theme object on BlockNoteView. Rejected. A theme object makes the Mantine view follow prefers-color-scheme, while the editor follows the admin theme's color-scheme (0003).
Keeping the live-region roles, or announcing as well as describing. Rejected. A region that arrives already filled is not reliably announced, and doing both announces a notice twice.
Documenting the pointer-only functions, or waiting for upstream to make the side menu keyboard-operable. Rejected. 2.1.1 fails for block colors and table structure until a keyboard route exists, and upstream has no date for one.
Driving TableHandlesExtension.addRowOrColumn() from the block menu. Rejected, because it resolves against the table under the pointer.
A role="img" marker, or an alt prop in 1.0. Rejected. A marker gives an image no text alternative, and an alt prop would diverge from upstream's image block schema. The name is text an author already writes.
Moving the public claim to WCAG 2.2 AA. Not taken for 1.0. 2.5.8 and 2.4.11 are measured and recorded, and the claim stays 2.1 AA.
Restoring focus to the editable on focusout when the next target is the page. Rejected 2026-09-16. It repairs the symptom after Tab has already landed on the popover's copy, and leaves Tab entering a menu that closes itself on the very key that opened it.
Keeping the side menu in the tab order until upstream fixes the copy. Rejected 2026-09-16. TypeCellOS/BlockNote#2854 is open, labeled a11y, with no linked pull request; the report this module could file sits, unposted, in docs/drafts/upstream-blocknote-side-menu-tab-focus.md.
Consequences¶
The side menu still appears only on hover. With the pointer over a block, Tab from a selection moves focus to the side menu's Add block button first, and that button hides and drops focus to the page, so the keyboard route into the formatting toolbar works only while no side menu is shown. Amended 2026-09-16. No longer so: Tab passes over the side menu whether or not the pointer shows it, and the route into the formatting toolbar works with the pointer over the block. The side menu still appears only on hover, and its pointer route is unchanged. Amended 2026-09-20. Measured directly in a browser: choosing Delete or a Colors swatch from the drag handle menu left document.activeElement on document.body, because the menu is upstream's own, built with returnFocus={false}, and neither action calls anything that refocuses. The same measurement, taken against the table row and column handle menus, the link toolbar's Remove link and the formatting toolbar, found focus lost only here: the table handle menus call editor.focus() themselves when they close, and the link toolbar's own mutation leaves the editor's DOM focus where it was. SideMenuOutOfTabOrder now restores it once a control vanishes out from under the pointer this way; see the amendment above. Amended 2026-11-02. Re-affirmed as a boundary this record keeps: the side menu stays hover-only, as upstream draws it, and its keyboard route is the Alt+F10 block menu, which offers every function the side menu and its drag handle menu do. A side menu operable from the keyboard itself waits on upstream: TypeCellOS/BlockNote#2854 tracks it, and carries this module's comment of 2026-10-30 on the Tab focus, docs/drafts/upstream-blocknote-side-menu-tab-focus.md.
A block's controls are reached with Tab only from a block that cannot be nested. When the text block before a media block or a callout has a sibling before it, Tab keeps focus in the editable, most likely because BlockNote nests that block. The block menu covers block operations, not a block's own controls. Amended 2026-09-16. Since 0014 a block may add its own functions to the menu; what stays true is that the menu does not reach a control a block draws inside itself. Amended 2026-09-20. canNestBlock() (node_modules/@blocknote/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts:194-199) is exactly "has a previous sibling": it resolves the position before the block's own node and asks whether a node comes before it there, nothing about the block's own type or content. The truthful route to a control a block draws inside itself is therefore the caret into the block plus Alt+F10, not Tab, because Tab indents whenever a previous sibling exists and only ever reaches the control on a block that happens to be first at its depth. js/src/blockmenu.tsx's blockMenuGroups() calling a block's own blockMenuGroups factory (0014) is that route already; the media module's own controls are the worked example (modules/blocknote_media/js/blockmenu.ts), and docs/extending.md's checklist for a new block names it as a requirement. Amended 2026-11-02. Re-affirmed as a boundary this record keeps: Tab indents any block that has a previous sibling, which is upstream's keyboard behavior for every block, and making it reach a control instead in a block that draws one would give Tab two meanings in the same position. The route this record commits to stays the caret into the block plus Alt+F10, with the block's controls registered through blockMenuGroups (0014); upstream tracks a keyboard route to controls inside blocks in TypeCellOS/BlockNote#3208. Amended 2026-11-02. The canNestBlock() sentence above, "exactly 'has a previous sibling'", is not true at 0.57.0, and the file it cites is not published on npm. canNestBlock() runs the nest command on a discarded transaction, and that command (sinkItem) succeeds only when the block has a previous sibling that is an ordinary block container, so a layout block before it does not count. The decision stands unchanged: for a media block or a callout after a paragraph the previous sibling is such a container, Tab indents, and the route to the block's own controls stays the caret into the block plus Alt+F10.
Toggle list items and toggleable headings have no open or close item in the block menu. The only exported toggle API writes local storage, and the shown state changes only through the toggle button's own click handler. Amended 2026-09-20. js/src/blockmenu.tsx's blockMenuGroups() now offers an Expanded menuitemcheckbox for a toggle list item and for a heading with isToggleable: true, whatever the block side menu surface is set to, because the button it operates is part of the block's own rendered content and not of that surface. It is checked from .bn-toggle-wrapper's own data-show-children attribute, found from the block's [data-id] the way the menu already finds its anchor, and activating it calls .click() on the button found the same way, which writes local storage exactly as a pointer click would and stores nothing new. Measured in a browser: no key changes what is shown while the caret stays in the block's own text; Tab does reach the button directly, but only when the block cannot be nested, the same limited route this record already documents for a media block's or a callout's own controls, so the menu entry is the reliable route rather than the only one. Giving the upstream button itself an accessible name and aria-expanded was considered and not done: it would mean a MutationObserver watching every toggle wrapper for the lifetime of every editor, decorating a DOM subtree the node view's own ignoreMutation callback already treats as partly exempt from ProseMirror's reconciliation, for attributes that callback does not enumerate; the risk was judged not worth taking on for a fix that belongs on the button itself, recorded in docs/drafts/upstream-blocknote-toggle-button-accessibility.md along with the non-injectable store. Amended 2026-10-31. BlockNote 0.56.0 rebuilt the toggles as block frames (blocks/ToggleWrapper/createToggleFrame.ts), and the button now has the name "Expand or collapse", from the dictionary's toggle_blocks.toggle_button, which js/src/dictionary.ts passes through Drupal.t(), and carries aria-expanded; its wrapper no longer carries data-show-children, and the wrapper now holds the block's content rather than sitting inside it. The Expanded item reads its checked state from the button's own aria-expanded, the button found as the direct child of the nearest .bn-toggle-wrapper around the block's content, and still activates it with .click(). The item stays: no key opens or closes a toggle from its text, and Tab still nests a block that can be nested before it reaches anything else (KeyboardShortcutsExtension.ts's handleTab() at 0.57.0, read from the source and not measured in a browser), so the menu entry remains the reliable route. The note on the button, docs/drafts/upstream-blocknote-toggle-button-accessibility.md, is deleted, since that release answered it; upstream tracks the keyboard route to controls inside blocks in TypeCellOS/BlockNote#3208.
A notice that appears after the editor was created, such as a file found missing while blocks render, is shown without being described or announced, because domAttributes are fixed at creation. A document already over a limit when the editor attaches is neither described nor announced. Amended 2026-11-02. No longer so: both are described through <editor id>-status and announced politely, as the amendment of the same date to the Decision section records. Amended 2026-11-02. The limit notice at attach is announced only to an author whose focus is already in the field, as the last amendment to the Decision section's paragraph on notices records.
The public stylesheet's default text colors for gray, red, orange and yellow changed. docs/rendering.md section 11 treats a stylesheet change as non-breaking, but a site comparing colors will see it.
The checklist markup changed, and the stylesheet's flex rule moved from .blocknote-block--check-list-item to .blocknote-check-list-item__label. A theme that styled the list item as the flex container targets the label instead.
The block menu is styled inline from BlockNote's own CSS variables, and its classes, bn-block-menu, bn-block-menu__items, bn-block-menu__item and bn-block-menu__label, are not part of the public HTML contract. bn-block-menu__items is the element inside the menu that holds the groups and carries the menu's scrolling, marked role="none": a menu that scrolled would clip the sub-menu drawn beside it, which is rendered inside the menu so that focus moving into it does not leave the menu. Its new strings, Block menu, Move up, Move down, Indent, Outdent, Checked, Cell colors and Table, go through Drupal.t().
The assertive announcement of an upload error has no browser test. uploadErrorMessage() is covered by vitest. Amended 2026-11-02. FileUploadTest now asserts it in both its refused upload tests: core's live region says the error with aria-live set to assertive, which core's own announce.js sets for that priority.
The drop cursor keeps the color of the scheme the editor attached in. A page whose scheme changes while the editor stays open, such as Gin's "auto" following an operating system switch, keeps the drop cursor of the scheme it attached in until the page reloads.
Inside a code block, both a first and a second Tab insert two spaces each; neither nests the block nor moves focus out of the editable. Shift+Tab, which the code block's own keymap leaves unhandled, is not a keyboard trap: it moves focus backward to the previous focusable field on the page, the same as an ordinary Shift+Tab out of any contenteditable would. This is not what an earlier manual test found, which reported that a second Tab leaves the editor; measured 2026-09-16 in a browser, against a two-block document in the blocknote_full format, a code block with a sibling before it so that nesting is the alternative a Tab could have taken instead of indenting. indentLineWithTab is left at upstream's default. Amended 2026-09-20. Escape already leaves the editor here, and everywhere else, through upstream's own OverrideEscape extension; the browser's own sequential focus navigation then carries a Tab or a Shift+Tab, sent afterward to wherever focus now is, to the next or previous field on the page, and Escape's departure is now announced. See 0022.
References¶
- WCAG 2.1: 1.3.1 Info and Relationships, 1.4.3 Contrast (Minimum), 2.1.1 Keyboard, 2.4.7 Focus Visible, 4.1.2 Name, Role, Value, and 4.1.3 Status Messages. WCAG 2.2: 2.4.11 Focus Not Obscured (Minimum) and 2.5.8 Target Size (Minimum).
- WAI-ARIA Authoring Practices, menu and menubar pattern: https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
- Drupal core 11.4.6:
core/misc/announce.js,core/modules/filter/templates/text-format-wrapper.html.twig. - BlockNote 0.54.2:
@blocknote/coresrc/editor/defaultColors.ts,src/extensions/TableHandles/TableHandles.ts,src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts,src/extensions/SideMenu/SideMenu.ts,src/extensions/DropCursor/DropCursor.ts,src/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts,src/blocks/ToggleWrapper/createToggleWrapper.ts,src/blocks/Heading/block.ts,src/blocks/ListItem/ToggleListItem/block.ts;@blocknote/reactsrc/components/Popovers/GenericPopover.tsx,src/components/SideMenu/DefaultButtons/AddBlockButton.tsx,src/components/SideMenu/DefaultButtons/DragHandleButton.tsx,src/components/TableHandles/TableHandle.tsx;@blocknote/mantinesrc/menu/Menu.tsx. js/src/a11y.ts,js/src/blockmenu.tsx,js/src/menus.tsx,js/src/editor.tsx,js/src/index.tsx,js/src/stripped.tsx,js/src/carried.tsx,js/src/limits.tsx,js/src/theme-isolation.css,css/blocknote.content.css,templates/blocknote-block--check-list-item.html.twig,src/Plugin/Editor/BlockNote.php.- Tests:
tests/src/FunctionalJavascript/WidgetBehaviourTest.php,BlockMenuTest.php,BlockControlsAccessibilityTest.php,LinkToolbarTest.php,AdminThemeIsolationTest.phpandEditorPolicyTest.php;tests/src/Kernel/RenderBlockHtmlTest.php;js/src/__tests__/a11y.test.ts,blockmenu.test.tsx,menus.test.tsxandeditor.test.ts. Amended 2026-10-31.WidgetBehaviourTestno longer drives the required attribute, the lock on a disabled or read-only textarea, the page's color scheme or the slash menu's guard in a table cell:js/src/__tests__/lifecycle.test.tsxattaches and detaches the widget for the first three, andsurfaces.test.tsxmounts the slash menu and the formatting toolbar with the real kit. Amended 2026-10-31.BlockMenuTestkeeps what it measures, the real Mantine menus a real pointer opens, and the real Alt+F10 chord; what the block menu offers and does, its keys, the focus it gives back and the table settings that narrow it are proven injs/src/__tests__/blockmenu.test.tsx, the cell menu's colors under those settings inmenus.test.tsx, and a header row the format forbids, taken off on load, inlifecycle.test.tsx. Amended 2026-11-02.lifecycle.test.tsxasserts the status element and the notices announced after creation. - TypeCellOS/BlockNote#2854 (open, labeled a11y, no linked pull request): https://github.com/TypeCellOS/BlockNote/issues/2854; the unposted report against it,
docs/drafts/upstream-blocknote-side-menu-tab-focus.md. The unposted report on the toggle button's name, state and store,docs/drafts/upstream-blocknote-toggle-button-accessibility.md. Amended 2026-10-31. That report is deleted, BlockNote 0.56.0 having fixed the name and the state; TypeCellOS/BlockNote#3208 tracks the keyboard route to controls inside blocks. - 0003, 0004, 0010.