Blockleaf

Accessibility

What the editor guarantees for keyboard and screen-reader users, what it asks of you, and how it is checked.

Blockleaf's chrome is built to be used without a mouse and announced correctly by a screen reader. That is a tested claim, not an aspiration: every surface the editor puts on screen is scanned with axe-core in a real browser on every commit, and the suite fails on a single violation.

What is checked

e2e/accessibility.spec.ts opens each surface and runs axe over it:

SurfaceHow it is opened
The editor at rest—
Slash menu/
Bubble menua text selection
Link editorMod + K
Block menuShift + F10
Drag handlehovering a block
Table, and its controls|||
Row menuCtrl + Alt + R
Grid picker/table
Callout/callout
Upload placeholdera failed upload
Imagea finished upload

A second Playwright project runs against an emulated phone, where nothing hovers — see On a phone.

A second suite builds a whole document — heading, list, table, link — without touching the mouse, because a surface that passes axe while being unreachable from the keyboard has passed nothing.

Colour contrast is checked here rather than in the unit suite on purpose. The jsdom tests have no layout, so contrast, focus visibility and anything that depends on where an element actually lands can only be seen in a browser.

Keyboard

Every menu opens, moves and closes from the keyboard.

KeysWhat happens
Shift + F10Block menu for the block holding the caret
Ctrl + Alt + RRow menu, inside a table
Ctrl + Alt + CColumn menu, inside a table
Mod + KLink editor, on a selection
↑ ↓Move within a menu
EnterRun the active item
EscapeClose the menu and return the caret

Menus keep focus on the menu element and point at the active item with aria-activedescendant, so the caret's position in the document is never lost. Each menu is also in the tab order: a menu that scrolls has to be reachable, or the items below the fold cannot be read.

Images and alt text

An image with no alt is the one critical failure an editor can hand a screen reader — it falls back to announcing the URL. So an uploaded image is never inserted without one.

The order is:

  1. alt from the upload adapter's result, if it returns one.
  2. Otherwise, the file's own name, with the extension dropped and - and _ turned into spaces — red-balloon_02.png becomes red balloon 02.
const adapter: UploadAdapter = {
	upload: async (file) => {
		const stored = await putSomewhere(file);
		return {
			id: stored.id,
			src: stored.url,
			// Pass one whenever you have it. A filename is a floor, not an answer.
			alt: stored.description
		};
	}
};

An empty alt is not a missing one

Returning alt: '' marks the image decorative and screen readers skip it, which is sometimes exactly right. The editor keeps an empty string as given and does not fill it in from the filename.

Authors can change any image's alt text from the image's own toolbar, and the value is stored on the node — see the schema.

What is still open

  • Empty table headers. A brand-new table has empty th cells until someone types in them, which axe reports as a minor finding. Header semantics — scope, a caption, announced cell position — are being addressed separately.
  • Right-to-left layout. The stylesheet uses logical properties in places and physical ones in others, and no suite checks it.

On this page