Accessibility

Auditing a rich text editor for accessibility in an afternoon

Four checks, no tooling, about twenty minutes. We ran them on our own editor and found a keyboard trap that had been shipping for years — while the accessibility statement said there wasn't one.

10 min read

We ran this procedure on our own editor. It took about twenty minutes and found four issues, three of them WCAG Level A — including a keyboard trap that had been shipping for years while our published accessibility statement said there wasn’t one.

That is the useful part. An accessibility statement is written by people reading the code; an audit is performed against the thing that runs. Those two drift, and nothing in a normal test suite notices. Below is the procedure, the console snippets, and what the answers mean. Run it on whatever editor you are evaluating. Run it on ours.

Why a checklist misses these

Automated scanners — axe, Lighthouse, WAVE — are good at the document and bad at the application. They will tell you an image lacks alt text. They will not tell you that focus cannot leave the editing area, because that is a property of a keystroke over time, not of the DOM at one instant.

Editor accessibility lives almost entirely in the part scanners cannot see: what happens when you press a key, and whether state changes are announced. So the checks below are behavioural.

Check 1: can you get out? (WCAG 2.1.2, Level A)

This is the most important one and takes ten seconds without any code. Click into the editor. Now press Tab repeatedly, then Shift+Tab. Try Esc.

If focus never leaves the editing area, stop — you have found a Level A failure. Not a degraded experience: a keyboard-only or screen-reader user who enters your editor is stuck there until they reload the page, losing whatever they typed.

The reason it is so common is that editors need Tab for indenting lists and moving between table cells:

// In the editing document's console:
const body = document.querySelector(".editor iframe").contentDocument.body;
body.focus();

let captured = null;
body.addEventListener("keydown", e => { if (e.key === "Tab") captured = e.defaultPrevented; }, false);

const before = body.textContent;
body.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", keyCode: 9, bubbles: true, cancelable: true }));

console.log({ tabSwallowed: captured, textChanged: body.textContent !== before });

tabSwallowed: true means Tab is being consumed. That is not automatically wrong — it is the correct behaviour for an editor — but it makes the next question mandatory: what is the documented way out?

There must be one, and it must be discoverable. An escape hatch nobody can find is not an escape hatch. Check whether the editing region’s accessible name mentions it, because that is the one string a screen reader reads on entry:

console.log(body.getAttribute("aria-label"));
// Ours now reads: "Rich text editor. Editing area. Press Escape to leave the editing area."

If the answer is a keyboard shortcut buried in a help page, that is a fail in practice even if a lawyer would argue it passes.

Check 2: how many times must you press Tab? (2.4.3)

A toolbar with forty buttons can be perfectly “operable” and still unusable. The ARIA Authoring Practices toolbar pattern is one tab stop for the whole toolbar, with arrow keys moving between buttons. Count what you actually have:

const shell = document.querySelector(".richtexteditor");
console.log({
  toolbars: shell.querySelectorAll('[role="toolbar"]').length,
  buttons:  shell.querySelectorAll('[role="button"]').length,
  tabStops: shell.querySelectorAll('[role="button"][tabindex="0"]').length,
});

Ours reported 53 tab stops. That is 53 presses of Tab between the page and the text you came to write, every time. It now reports one per toolbar.

Then check the arrow keys actually work — plenty of editors set the ARIA attributes without implementing the behaviour they promise. Focus a toolbar button and press . If focus does not move, the role="toolbar" is decorative, and a screen reader user has been told to expect navigation that does not exist. That is arguably worse than no role at all.

Check 3: is state announced, or only drawn? (4.1.2, Level A)

Put the caret in bold text. Sighted users see the Bold button highlight. Ask what a screen reader is told:

const toggles = [...shell.querySelectorAll('[role="button"]')]
  .filter(b => /bold|italic|underline|justify|list/i.test(b.getAttribute("aria-label") || ""));

console.log(toggles.map(b => ({
  name: b.getAttribute("aria-label"),
  pressed: b.getAttribute("aria-pressed"),   // null means: not announced at all
})));

pressed: nullmeans the state exists only as a CSS class. The button announces “Bold, button” whether bold is on or off. A user cannot tell what formatting they are typing in — they have to type a character, navigate back over it, and infer.

Ours reported 0 of 6 toggle buttons exposing state. It now reports 8 of 8.

The same check for menus:

const popups = [...shell.querySelectorAll("[aria-haspopup]")];
console.log(popups.map(p => p.getAttribute("aria-expanded")));   // null = open/closed never announced

One refinement worth insisting on: ask whether aria-pressed is derivedfrom the editor’s own state or maintained separately. If it is maintained separately it will eventually disagree with the highlighted button, and a wrong announcement is worse than a missing one. Mirroring the existing active-state class means the two cannot diverge.

Check 4: does the exported document keep its structure?

Editors are usually judged on the editing experience, but most of the accessibility consequence is downstream — in the HTML, PDF or Word file someone else opens.

Export a document with headings, a list and a table with a header row. Then check what survived. For HTML, confirm h1h6, ul/ol/li and th with scope are still there rather than divs with font sizes.

For PDF, the test most people run — select text, Ctrl+F — only distinguishes a text PDF from a scan. Ask the harder question: is it tagged? An untagged PDF gives assistive technology a flat stream of words with no headings, no list semantics and no table relationships, and it fails Section 508 and EN 301 549 regardless of how selectable the text is. We wrote that up separately in a searchable PDF and an accessible PDF are different features.

Reading the results honestly

Two traps in interpreting what you find.

An iframe’s document.activeElement lies.When the iframe’s window is not focused it falls back to <body>, so it cannot tell you whether focus left. We wrote an assertion on it and got a false failure. Check the host document’s activeElement instead — while editing it is the iframe element; after a successful escape it is something else.

Check for under-claims too.Reviewing our own statement we found it listed “table headers are not detected” as a known gap, when in fact the accessibility checker already flags tables with no header cells and offers a one-click repair to <th scope="col">. Accuracy runs in both directions, and a statement that undersells is still a statement nobody should trust.

What to ask the vendor

Once you have run the four checks, the questions that separate a real answer from a brochure are specific:

A vendor who answers these precisely has done the work. A vendor who answers “yes, we’re WCAG 2.1 AA compliant” without specifics has usually answered from a document rather than from the product, which is exactly the gap this procedure exists to find.

We publish our own findings, including the ones that were embarrassing, at /docs/accessibility. If you run this procedure against our editor and find something we have missed, we would rather hear it from you than not hear it.


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.