Skip to content

fix(react): dialog accessibility: Close on phones, focus that stays put, cited cues (od-a11y) - #169

Open
dgesteves wants to merge 2 commits into
mainfrom
fix/od-a11y-dialog-a11y
Open

dgesteves wants to merge 2 commits into
mainfrom
fix/od-a11y-dialog-a11y

Conversation

@dgesteves

@dgesteves dgesteves commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Roadmap item od-a11y: OD2-19, OD2-20 and OD2-58 in one PR, as they all edit dialog-panel.tsx, labels.ts and styles.css.

What was wrong

Measured on main with Playwright's Chromium against the script-tag embed and the mock endpoint:

  • No way to close the dialog on a phone (OD2-19). At 390×844 with touch emulation, the accessibility tree had no close control (only the text "esc"), and with an answer showing, the dialog ran from 8 to 838 px of an 844 px screen. A phone has no Escape key.
  • Focus fell to the dialog after Rate, Stop or Retry (OD2-19). Pressing Enter on "Yes, it helped", Stop or Retry left document.activeElement on DIV.ask-dialog, because the control unmounted. A screen reader reads the dialog out again and the keyboard loses its place.
  • Cited and uncited sources looked alike to screen readers (OD2-20). Source links were named like Docusaurus Install it /docs/docusaurus#install-it: no number to match a citation ("Source 2: …"), no cited state, and the raw URL.
  • Every error was announced twice (OD2-20). On a 500, the alert and the status region both said "Something went wrong (500). Please try again."
  • Numbered steps came out as 1, 1, 1 (OD2-20). Steps with a code block or a blank line between them rendered as separate <ol>s with no start.
  • OD2-58: in forced colors the selected suggestion looked the same as the others (no background, no outline), cited and uncited badges were identical, and the navbar .ondocs-button had 0px none border. On touch screens the thumbs were 34×28, the other buttons 28 px tall, and a citation chip's tap area was 16×15.5 px, 2.6 px from its neighbour. ARIA nits: the combobox stayed aria-expanded="true" while the answer showed, the ↵ and esc glyphs were in names and text, the title was read twice (cmdk's hidden label repeats it), and thread questions were paragraphs.

What changed

  • Close button. Dialog.Close in the input row, named by a new close label ("Close"). It shows under (hover: none) in the esc hint's place, 44×44 px, with its icon lined up with the search icon. Desktop is unchanged. Like Stop, it keeps Enter from cmdk's <Command>, which otherwise cancels Enter and selects the highlighted item: a phone or tablet with a hardware keyboard would ask the selected suggestion, or do nothing with an answer showing, instead of closing.
  • Focus. A thumb moves focus on to "Add a comment", and a sent comment to "New question", when the pressed control had focus. The move happens when the rating's response arrives, so it only happens if focus was lost by then (document.activeElement === document.body): a visitor who tabbed elsewhere while the rating was sent stays there. Stop and Retry move it to the input when pressed from the keyboard or a screen reader (event.detail === 0). I did not do that for a tap: focusing the input from a tap would bring up the phone's keyboard over the answer. A test locks this in.
  • Sources. Each link starts with visually hidden text from three new labels, source ("Source {n}:", while the answer streams), sourceCited ("Source {n}, cited:") and sourceNotCited ("Source {n}, not cited:"). The URL span is aria-hidden. Cited sources have their number filled (accent-fg on accent: 5.36:1 light, 10.62:1 dark), so they differ by more than a shade. I kept one list rather than splitting it into "Cited" and "Also searched", which would have changed the layout and the order.
  • One announcement. statusMessage returns nothing for an error; the alert says it. Retry stays inside the alert, as test/misconfigured-dialog.test.tsx expects.
  • Numbered steps. <ol start> takes the first item's number, so split steps read 1, 2, 3 and a list the model starts at 3 starts at 3.
  • ARIA nits. cmdk sets aria-expanded to true and its own aria-labelledby after spreading the caller's props, so both are set on the input from a ref once cmdk has rendered: expanded only while the list has something to pick, and labelled by the dialog's own title. cmdk's label is left empty, so the title is no longer read twice. I tried aria-label with an empty cmdk label first; dom-accessibility-api (what Testing Library uses) gave the input no name, so screen readers may not fall back either. The ↵ and esc <kbd>s are aria-hidden. Thread questions and the Sources heading are h3 under the dialog's h2 title, styled as before (font: inherit, so host heading styles don't leak in).
  • Coarse pointers. Under (pointer: coarse), Stop, Retry, New question, the thumbs, Send and the comment field are 44 px tall. Each citation gets a transparent ::after with inset: -6px. A citation right after another ([1][2], [1, 2], or with only a space between) is marked data-after-citation when it renders, and gets margin-left: 12px, twice the inset, so the two tap areas do not meet. Elsewhere a citation keeps its usual 0.12em margins, so there is no gap before a full stop. A CSS .ask-citation + .ask-citation rule could not do this: it also matches [1] some words [2], because sibling selectors skip text.
  • Forced colors. The selected suggestion gets outline: 2px solid Highlight, cited badges get Highlight/HighlightText with forced-color-adjust: none, and .ondocs-button gets border: 1px solid ButtonText, in src/embed/button.css and src/docusaurus/launcher.css.
  • JS budget for the Next.js example (scripts/js-weight.mjs, outside the od-a11y file list). The site inlines ondocs/react/styles.css into its page's RSC payload, which js-weight counts as JavaScript before interaction, so every new rule counts there. With the new styles the page measures 180.5 KB locally, over the 180 KB budget. The budget is now 182 KB, with a comment saying why, and the open figures are updated to this branch's measurements.
  • Docs and changeset. The four labels are in the labels table, and the Accessibility section describes the new behaviour (synced with pnpm build && pnpm examples:sync). The changeset is a patch for ondocs.

How it was tested

Run against main's source, the three test files fail 17 tests: the 16 new ones below other than the guard, and the one updated assertion (on main the question is a paragraph). The guard passes on both.

  • test/keyboard-dialog.test.tsx: focus after a thumb, a sent comment, Stop and Retry (Enter); a tap on Stop leaves the input unfocused (the guard, which passes on main too); focus stays on "New question" when the visitor moves it there while the rating is sent; the Close button closes the dialog with an answer showing, closes on Enter both with a suggestion selected (and asks nothing) and with an answer showing, and takes labels.close.
  • test/react.test.tsx: steps split by code or blank lines start at 1, 2, 3, and a list can start at 3; an error leaves the status empty; source names carry the number and cited state, and the URL is aria-hidden; the combobox is named once by the title, collapsed while the answer shows and expanded again for a follow-up; glyphs are aria-hidden; thread questions and Sources are level-3 headings; a citation right after another is marked data-after-citation, and one after words is not. One existing assertion now finds the question over an error by its heading role instead of selector: 'p'.
  • test/touch-and-forced-colors.test.ts (new): the Close and esc rules under (hover: none), the 44 px and citation rules under (pointer: coarse) (no margin on every citation, and a follower's margin at least twice the tap area's inset), the forced-colors rules, and the navbar button border in both stylesheets.

The review fixes add four tests and change one (the coarse-pointer rules). Against the branch's first commit, all five fail; with the fixes they pass.

Gate, all passing: pnpm install --frozen-lockfile --prefer-offline, pnpm format:check, pnpm lint, pnpm typecheck, pnpm test (95 files passed, 1 skipped; 1304 tests passed, 10 skipped), pnpm build, pnpm examples:check (32 pages), and pnpm validate (exit 0, with the four example builds).

The JavaScript budgets, run locally after those builds: node scripts/js-weight.mjs docusaurus starlight vitepress next embed --check passes:

Site Before interaction (budget) On open (budget)
docusaurus 177.7 KB (182) +27.7 KB (31)
starlight 36.1 KB (40) +93.1 KB (97)
vitepress 61.6 KB (64) +96.1 KB (100)
next 180.5 KB (182) +27.8 KB (31)
embed 3.2 KB (4) +98.5 KB (102)

The first version of this description said the change only touched the lazy dialog chunk. That was wrong for the Next.js site: its HTML has the dialog's styles in a <style> tag and again in the self.__next_f.push payload script, and the script copy counts before interaction. On the other sites the new code and styles do load only with the dialog. node scripts/embed-size.mjs passes (embed-dialog.global.js 98.5 KB gzipped, budget 110; embed.global.js 3.2 KB), and node scripts/mobile-width.mjs passes (35 pages at 390 px, none scrolls sideways). With gzip -9, embed-dialog.global.js is 100,768 bytes and embed.global.js is 3,329. I ran both browser scripts on ports 4100 to 4999 rather than their built-in ports.

Browser check (Playwright Chromium, the script-tag embed built from main and from this branch, mock endpoint). The results were the same in light and dark:

Check main this branch
390×844 touch: close controls in the accessibility tree 0 1, 44×44, and a tap closes the dialog
390: esc hint shown yes no (Close in its place)
390: thumbs / New question 34×28 / 99.3×28 44×44 / 99.3×44
390: citation chip, its tap area 15.7×15.7, 16×15.5 15.7×15.7, 26.75×26.75 (hit-tested in 0.25 px steps)
390: gap between [1][2], and between their tap areas 2.6 px 13.3 px, with 2.2 px between the tap areas
Numbered steps (ol.start) 1, 1, 1 1, 2, 3
1440: focus after Enter on a thumb DIV.ask-dialog "Add a comment"
1440: focus after sending a comment not reached "New question"
1440: focus after Enter on Stop / Space on Retry DIV.ask-dialog the input
1440: status text on a 500 the error again empty (the alert says it)
1440: combobox aria-expanded with the answer showing true false
1440: a source's name Docusaurus Install it /docs/docusaurus#install-it Source 2, cited: Docusaurus Install it
1440: Sources heading level 2 3
1440: Close / esc hint hidden / shown hidden / shown
Forced colors: selected suggestion outline none 2px solid Highlight
Forced colors: cited badge same as uncited Highlight fill
Forced colors: navbar button border 0px none 1px solid

Closing and reopening the dialog with an answer kept leaves the input named by the title and collapsed.

The review fixes, checked the same way (Playwright Chromium, the script-tag embed with data-suggestions, a real handler with a scripted answer containing [1][2] and [1, 2], and an onFeedback that takes 1.5 s). The two columns are this branch's first commit and the branch now:

Check first commit now
390×844 touch, idle, suggestion selected: Enter on Close dialog stays open, the suggestion is asked (1 request) closes, no request
390×844 touch, answer showing: Enter on Close dialog stays open closes
390: tap areas of [1][2] meet (no clear space measured between them) 2.2 px apart
390: gap between a citation and the full stop after it 5.4 px 1.3 px (the same as at 1440)
1440: Enter on a thumb, then wait "Add a comment" "Add a comment"
1440: Enter on a thumb, then focus "New question" while the rating is sent pulled back to "Add a comment" stays on "New question"

Not in this PR

  • OD2-33, the real screen-reader pass (VoiceOver with Safari, NVDA with Chrome), stays with the owner. It should confirm that the cited state reads well, that one announcement per error holds, and where focus goes after a VoiceOver double-tap on Stop on iOS (a synthesized touch, so focus stays put).
  • Retry is still inside the error's role="alert", so the one announcement ends with "Retry" ("Something went wrong (500). Please try again.Retry"). Moving it out of the alert means changing test/misconfigured-dialog.test.tsx too, which is outside this bundle, so it is a follow-up.
  • From the same screen-reader nits finding:
    • aria-expanded on the plugins' navbar buttons is in src/starlight/Search.astro and src/docusaurus/theme/Navbar/Search.tsx, outside this bundle's files.
    • The default footer still shows "esc close" next to the input row's esc hint on desktop. Dropping it would leave the footerClose label unused.
  • The other OD2-58 findings in the evaluation are follow-ups:
    • suggestions and source titles cut with an ellipsis on narrow screens;
    • the dialog input's focus ring;
    • the VitePress launcher kbd at 4.49:1 and the comment field's 1.5:1 border;
    • McpInstall's <pre> not reachable by keyboard (its Astro twin is outside these files);
    • the website and example label nits.

… forced colors

- A Close button (labels.close) in the input row on touch screens, in the
  esc hint's place, 44 by 44 px. A phone has no Escape key, and with an
  answer showing the dialog left an 8 px strip of page to tap.
- Focus moves on when the pressed control goes away: a thumb to "Add a
  comment", a sent comment to "New question", and Stop or Retry pressed
  from the keyboard to the input. It fell to the dialog's container.
- Each source's name starts with its number and whether the answer cites
  it (labels.source, sourceCited, sourceNotCited); the URL is aria-hidden,
  and cited sources have their number filled.
- An error is announced once, by its alert, not again by the status.
- The combobox is expanded only while its list shows, and named once, by
  the dialog's title; the key glyphs are aria-hidden; thread questions and
  Sources are h3 headings.
- Numbered steps split by code or a blank line keep their numbers.
- Touch screens get 44 px buttons and a 26 px tap area on citations;
  forced colors outline the selected suggestion, fill the cited sources'
  numbers and border the navbar button.
…ng; Next budget

- Enter on the Close button closes the dialog. The command palette around
  it took Enter: with a suggestion selected it asked that suggestion, and
  with an answer showing it did nothing. Close keeps its own keys, as Stop
  does.
- After a thumb, focus moves on to "Add a comment" only if it was lost.
  A visitor who moved focus while the rating was sent kept being pulled
  back when the response landed.
- On touch screens, only a citation right after another ([1][2], [1, 2])
  gets extra room: 12 px, twice the 6 px reach of each tap area, so the
  two areas no longer meet. Next to words and punctuation a citation keeps
  its usual 0.12em margin, without the 5.4 px gap before a full stop.
- js-weight: the Next.js site inlines ondocs/react/styles.css in its page
  payload, so the new dialog styles count before interaction there: 180.5
  KB, over the 180 KB budget. Raised to 182, and the open figures updated.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant