Skip to content

a11y: Provide an AT-only read view of pad content (line-by-line + link navigation) #7778

@JohnMcLear

Description

@JohnMcLear

Background

Follow-up to #7255. Murphy's
2026-05-16 re-test:

The biggest problem is that you still can't cycle through the text properly
line by line to press links and such. You can only really get to links if
you already know where they are and navigate directly to them.

PR #7777 addresses the targeted
toolbar / measurement-node / online-count AT regressions, but this complaint is
structural and needs a separate design effort.

Root cause

Two structural exposures keep AT from navigating pad content the way it
navigates a regular web page:

  1. Each line renders as <div class=\"ace-line\">, not <p> (src/static/js/domline.ts:64).
    AT users rely on the <p> element for paragraph / line-by-line navigation
    (P key in NVDA/JAWS rotor). With <div>, the whole pad is one
    undifferentiated block.

  2. innerdocbody carries role=\"textbox\" aria-multiline=\"true\"
    (src/static/js/ace.ts:299). This tells AT the editor is a flat editable
    surface — links inside (<a href>) often don't surface in the rotor /
    elements list because the screen reader treats the editor as a text input,
    not a document.

Headings already work (ep_headings2 emits <h1><h4> via the
aceDomLineProcessLineAttributes hook).

Why we can't just swap <div><p>

The naïve fix — change document.createElement('div') to 'p' in
domline.ts — was scoped and rejected:

  • Plugins extensively target line <div>s with selectors like
    iframe.contents().find('div.ace-line'), .innerdocbody div,
    div[data-author=…]. Silently switching the tag would break a long tail of
    plugins with no compile-time signal — bugs would trickle in over weeks.
  • <p> is phrasing content per HTML5 and cannot contain block-level children
    (<ul>, <ol>, etc.). Existing list lines wrap themselves with
    <ul><li>…</li></ul> inside the line node — browser auto-fixup would close
    the <p> before the <ul>, breaking rendering.
  • Even with the per-line list pattern, AT would see N single-item lists
    (one <ul> per line) instead of one multi-item list — also wrong.

A flag-gated, opt-in switch with a deprecation cycle is theoretically possible
but is a multi-release rollout, not an a11y bug fix.

Proposed approach: AT-only read mirror

Render a visually-hidden <article role=\"region\" aria-label=\"Pad content\">
mirror outside the contenteditable iframe. Sighted users see nothing change.
Plugin selectors keep hitting the live editor's <div class=\"ace-line\">s.
AT users get a regular document tree to browse.

Shape

  • New module e.g. src/static/js/at_mirror.ts.
  • Subscribes to changeset apply / editEvent and rebuilds the mirror,
    throttled (~500 ms debounce) — AT doesn't need realtime updates while
    the user is typing.
  • Line model → mirror DOM mapping:
    • Plain line → <p> containing inline <a href> for URL spans.
    • Heading line (ep_headings2 heading attribute) → <h1><h4>.
    • List line → <li> nested inside a single <ul> / <ol> per consecutive
      run of list lines at the same level (not one ul-per-line like
      ExportHtml.ts currently does).
    • Code line → <pre><code>.
  • New skip link near the existing "Skip to editor":
    "Read pad content (screen reader view)" → focuses #at-pad-mirror.
  • Existing skip-to-content link still jumps to the editable view for editing.

Locale keys

  • pad.editor.atMirror.skipLink = "Read pad content (screen reader view)"
  • pad.editor.atMirror.region = "Pad content"

Out of scope for this issue

  • Live edit support inside the mirror (would require a typing handler that
    forwards back to the editor — not in scope for v1).
  • Author colors / highlighting in the mirror (probably noise for AT).
  • Authorship metadata in the mirror.

Acceptance criteria

  • Mirror is visually hidden (sr-only clip-rect pattern) but exposed to AT.
  • Skip link is reachable from the first Tab on a fresh page (currently
    that slot is already taken by #skip-to-content; new link sits next to
    it).
  • Mirror updates within ≤ 1s of a content change in the live editor.
  • Headings appear in the AT rotor / elements list (<h1>-<h4>).
  • Links appear in the rotor / links list with their original href and
    visible text as the accessible name.
  • Lists are merged across consecutive list lines into a single <ul> or
    <ol> per run.
  • No regression to the existing skip-to-content / line-number / toolbar
    a11y fixes from Inaccessibility to screenreaders #7255.
  • Manual testing confirmation from a screen-reader user (ping @StrangeGirlMurph
    once a draft build is available).

Effort estimate

3–5 days of implementation + at least one round of real-screen-reader testing.

Refs #7255 #7777

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions