Accessibility
What assistive technology gets from an Edytor view, how to name the editor, the keyboard paths into its menus, toolbar and column bands, the live announcements, right-to-left text, and the known gaps.
An Edytor view is one editable region, a role="textbox" with aria-multiline="true", whose blocks render as the elements their kinds declare (headings as h1–h3, quotes as blockquote, code as pre). Its chrome (block handles, menus, the toolbar, the column bands) lives beside it in the overlay, and each piece carries the WAI-ARIA 1.2 pattern it follows. This page lists what you get and what you need to add.
Name the editor
A textbox needs an accessible name. Pass one of these props; they go to the root element:
| Prop | Use it for |
|---|---|
aria-label |
A name of its own: aria-label="Page content". |
aria-labelledby |
The id of the element that names it, such as the page title above it. |
aria-describedby |
The id of a hint or an error message. |
id |
A <label for>, a skip link or your own aria-labelledby elsewhere. |
<script lang="ts">
import { Edytor } from 'edytor';
</script>
<h1 id="page-title">Meeting notes</h1>
<p id="page-hint">Type / for blocks.</p>
<Edytor aria-labelledby="page-title" aria-describedby="page-hint" />
All four follow updates. A readonly view also carries aria-readonly="true".
Menus and popups
While a menu is open, the element that holds the keyboard names it with aria-controls, and names its highlighted row with aria-activedescendant, so a screen reader reads each row as the arrows move, without the focus leaving the text:
| Popup | Holds the keyboard | On the editor’s root | On the control that opened it |
|---|---|---|---|
Slash menu, opened by typing / |
The root | aria-controls (the listbox), aria-haspopup="listbox", aria-activedescendant (the highlighted option) |
none |
Slash menu opened by a handle’s + |
Its search field, a combobox with aria-controls, aria-activedescendant and aria-expanded="true" |
aria-controls, aria-haspopup="listbox" |
the +: aria-expanded="true", aria-controls, aria-haspopup |
A plugin’s trigger menu: mentions (@, “People”), page links ([[, “Pages”) |
The root | aria-controls (the listbox, named by the trigger’s name, aria-busy while a search is pending), aria-haspopup="listbox", aria-activedescendant (the highlighted option) |
none |
| Block menu | Its search field: aria-controls (the menu, or the open flyout, Turn into or Color) and aria-activedescendant (the highlighted menuitem, a colour’s menuitemradio) |
aria-controls, aria-haspopup="menu" |
the grip: aria-expanded="true", aria-controls, aria-haspopup="menu" |
| Selection toolbar | The root, until Alt+F10 | aria-controls (the toolbar), aria-keyshortcuts="Alt+F10" |
none |
| The menu a URL pasted on an empty line opens (embed, bookmark) | The root | aria-controls (the listbox), aria-haspopup="listbox", aria-activedescendant (the highlighted option) |
none |
| A table row’s or column’s grip menu | The menu itself, focused, with aria-activedescendant (the highlighted menuitem; one that cannot run is aria-disabled and the arrows pass it) |
aria-controls, aria-haspopup="menu" |
the grip: aria-expanded, aria-controls, aria-haspopup="menu" |
| A code block’s language list | Its search field, a combobox with aria-controls and aria-activedescendant (the highlighted option) |
aria-controls, aria-haspopup="listbox" |
the language button: aria-expanded, aria-controls, aria-haspopup="listbox" |
The attributes are present only while the popup is open. The root is a textbox, which takes no aria-expanded in WAI-ARIA 1.2, so the open state is on the control that opened the popup. Ids are unique per view (edytor-<n>-…), so several editors on a page never share one.
The menus’ rows are not Tab stops: the field or the root owns the keys (↑, ↓, Enter, Escape). The slash menu’s sections are groups named by their headings (role="group" with aria-label).
The toolbar from the keyboard
With a text range selected and the toolbar shown, Alt+F10 moves the focus to its first button (the editors’ convention: Google Docs, TinyMCE, CKEditor). The bar is one tab stop (role="toolbar"): ← and → walk its buttons, Home and End go to the first and the last, Enter or Space press one, and Escape gives the focus back to the editor over the same selection. Keys pressed in the bar never reach the document.
Your menus
The package draws no menu: your snippets do (Menus and handles). The ids, the keys and the publishing above are the controllers’ attachments: a snippet that uses them gets this keyboard and this ARIA (the demo’s markup is the one the accessibility tests run). See Menus and handles.
{@attach controller.popup}on the list (or the toolbar) gives it its id, and its role and name unless you set them, and publishes it to the root while it is in the page (aria-controls,aria-haspopup, andaria-activedescendantwhen the root holds the keyboard), withdrawn when it goes.{@attach controller.keys}on the element that holds the keyboard (the block menu’s or the+menu’s field, the toolbar) takes the menu’s keys, focuses it when it mounts, and makes a field name the list and its highlighted row; on the toolbar, it is the roving tab stop and Escape.{@attach controller.option(index)}on each row gives it the id the keyboard’s owner names, its role and itsaria-selectedoraria-checked(a controller’s row payload carries the same asoption). A row without it is still listed; onlyaria-activedescendantis left out.- The block menu’s
flyoutMenu, the toolbar’slinkField(an id unique per editor, for alabel), the image toolbar’sbarandaltFielddo the same for those parts, and a block handle’sgripandpluskeep thearia-expanded,aria-controlsandaria-haspopupof the menu they opened. - A control inside a block (a to-do’s checkbox, a callout’s icon…) takes its attachment the same way,
{@attach payload.control}: a checkbox drawn as abuttongetsrole="checkbox"andaria-checkedfrom it.
The controllers still expose the ids, for markup that names them itself: every list’s listId and rowId(index) (rows are named by position: the slash and trigger menus, the code language list, the callout picker, the pasted-link menu, a table grip’s menu), the block menu’s menuId, flyoutId and rowId(row), the toolbar’s barId and linkFieldId. The popup attachment is the one way a surface reaches the root.
Block handles
Name each handle after its block’s kind: your handle snippet receives it as label, read from the kind’s preset label, and names its buttons in its words (the demo’s grip “Heading 1 block: drag to move, click for actions”, its + “Add a block below Heading 1 (Alt: above)”). With a grip focused, Alt+↑ ↓ → ← move the block (the grip’s attachment sets aria-keyshortcuts); Enter or Space opens its block menu. From the text, Mod+Shift+↑/↓ moves the caret’s block and Tab/Shift+Tab nest it (see Hotkeys).
Column widths from the keyboard
With the columns plugin, the band in the gap between two columns is a focusable separator (aria-orientation="vertical", named “Resize columns”), valued by the left column’s share of the pair in percent (aria-valuenow, 0 to 100). The bands of the layout that holds the caret are in the page, after the editor, so Shift+Tab from the content after the editor (or a screen reader’s cursor) reaches them, and a focused band stays while it holds the focus.
| Key | Effect |
|---|---|
| → / ← | Move the gap by 1% of the layout’s width: the left column grows or shrinks, the right one the other way |
| Shift+→ / Shift+← | By 10% |
| Home / End | The left column, then the right one, at its narrowest (minWidth) |
| Escape | Give the keys back to the editor |
Each press writes both columns’ weights, keeping their sum, as one undo step, as a pointer drag’s release does. Focused, the band shows its guide in the resize color. Every key pressed on a band is the band’s: none reaches the document.
Announcements
Changes a screen does not say by itself are announced in a polite live region (role="status", aria-live="polite") in the overlay, [data-edytor-live], visually hidden:
| Change | Announced |
|---|---|
A move of one block or several: a drag, the handle’s Alt+arrows, Mod+Shift+↑/↓, the block menu’s Move, edytor.move |
“Moved Heading 1 block down”, “Moved 3 blocks up”, “Moved Text block” for a drop |
| Tab or Shift+Tab, and the handle’s Alt+→/← | “Indented To-do list block”, “Outdented 2 blocks” |
| A delete of selected blocks: Backspace, Delete, cut, the block menu’s Delete | “Deleted 2 blocks”, “Deleted Heading 2 block” |
Only this view’s own gestures are announced, never a collaborator’s, and a refused move says nothing. A plugin can announce its own changes with edytor.announcer.say(message). The words come from <Edytor labels> (movedUp, deleted, block, …: functions of what moved, so another language orders them its own way) and the kind’s preset label; see Localization.
Right-to-left text
Each block takes the direction of its own text: the first strong character of its text (a letter of any script, as dir="auto" reads it, Unicode’s UAX #9) sets dir="rtl" or dir="ltr" on the block element. A Hebrew or Arabic line in an English page is right to left: it aligns right, its caret starts on the right, and the arrow keys move visually (← goes forward in it), as in a right-to-left page; an English line in an Arabic page stays left to right. A block holding no strong character (empty, digits, punctuation) has no dir and keeps its parent’s, so an empty line in a right-to-left page stays right to left. Set dir="rtl" on an element around the editor for a right-to-left page.
The direction is set on the block, not on its text, because browsers move the caret by the direction of the paragraph, not of an inline run. Inside a line, a Shift+arrow step is the browser’s: Chromium and WebKit extend visually, Firefox logically (its bidi.edit.caret_movement_style).
Colors
The demo’s chrome and the code block’s syntax colors meet WCAG AA contrast (4.5:1) on their backgrounds; the secondary gray is #73726e (--notion-text-secondary in the Notion theme). The list kinds keep their li, ul and ol tags for styles and the HTML export, with role="none": a list’s items are sibling blocks, never the only children of one list element, so assistive technology would read each as an orphan list item.
Testing
tests/editor-dom/a11y.spec.ts runs axe-core (@axe-core/playwright) over the demo editor and its overlay against WCAG 2.2 A and AA, idle and with each menu, the toolbar and a column band in use, in Chromium, Firefox and WebKit; tests/editor-dom/bidi.spec.ts checks the caret and selection in Hebrew and Arabic lines mixed with English. Run the same audit over your own page:
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
test('the editor passes axe', async ({ page }) => {
await page.goto('/notes');
const { violations } = await new AxeBuilder({ page })
.include('.editor-wrapper')
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(violations).toEqual([]);
});
Screen readers
An automated audit checks markup, not what a person hears. The manual passes are recorded here:
| Reader | Browser | Status |
|---|---|---|
| VoiceOver (macOS) | Safari | Not yet recorded |
| NVDA (Windows) | Firefox, Chrome | Not yet recorded |
A pass covers: the editor’s name on focus; typing, then reading back by line and by word; the / menu read row by row as the arrows move and its pick; the + and block menus; Alt+F10 into the toolbar and Escape back; a move and a delete announced; a column band’s value as it changes; a Hebrew line read in order.
Known gaps
- Target size. The grip, and a column block’s
+, are Notion’s 18px wide, under WCAG 2.2’s 24px minimum for a pointer target (2.5.8, AA); the keyboard paths above reach the same actions. - Leaving the editor with the keyboard. Tab and Shift+Tab nest and unnest blocks inside the editor, as in Notion, so they do not move the focus out of it. Give keyboard users a way out next to the editor (a skip link, or a landmark the page’s other controls follow).
- List semantics. A bulleted, numbered or to-do list is read as its lines, not as a list with a count (see Colors above for why).