[lexical-a11y][lexical-react][lexical-playground][lexical-website] Feature: @lexical/a11y framework-agnostic accessibility helpers + WCAG AA reference adoption#8591
Open
mayrang wants to merge 105 commits into
Conversation
…ened from the toolbar
…@lexical/react/useFocusTrap
…d apply to the toolbar
…+F10 editor-to-toolbar jump, Escape return)
…eleaseOnEscape opt-in
…table/read-only via aria-live)
…cal default editor.blur() already covers WCAG 2.1.2)
…d mobile screen reader scope
… Chrome and Safari (normal window)
…calNode (getRole / getAriaLabel)
…le/aria-label directly in EquationNode
…k reference table
…und-1 review fixes (devtools paths, sidebar, isHTMLElement, stopPropagation, doc tone)
…eview fixes (move EditorModeAnnouncePlugin to @lexical/react, drop redundant aria-readonly, polish)
…apply useRovingTabIndex to FloatingTextFormatToolbar
…atToolbar The floating text format toolbar rendered as a plain div, so screen readers got no toolbar grouping for the bold / italic / underline / code / link buttons. Mirror the main toolbar's `role="toolbar"` + `aria-label`.
…s + Safari Tab cycle fix Boundary-only Tab cycling let Safari's default Tab route through the browser chrome (visible URL-bar flash between trap boundaries). The hook now manages every Tab / Shift+Tab explicitly with an index-based wrap, and a document-level `focusin` listener pulls focus back if it ever escapes the container. New `initialFocus` option (`'firstFocusable'` default, `'container'`). `Modal` passes `'container'` so screen readers announce the dialog body via `aria-labelledby` before any control — the previous behavior landed first focus on the close (X) button (an APG dismiss-as-first-focus anti-pattern). `Modal.css` adds an inner `:focus` outline + forced-colors override so the keyboard-focus indicator is visible (WCAG 2.4.7).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… core as createRefCountedRegistry The a11y extensions had a private ref-counted container registry. Generalize it from HTMLElement-keyed to a `<Key, Options>` primitive and move it into the core `lexical` package as `createRefCountedRegistry`, so core code can reuse it too (e.g. the document-keyed ref-count in addRootElementEvents, or the window-keyed one in @lexical/dragon). - `createRefCountedRegistry(activate?)` returns a `ManagedRefCountedRegistry`: `register(key, options) => idempotent dispose`, ref-counted (first registration activates, last release tears down), plus owner-only `setActivate` (for deferred binding) and `dispose`. Keys compare by identity, so DOM elements, Document, Window, or opaque handles all work. - `@lexical/a11y` now imports it; `ContainerRegistry<Options>` becomes an alias for `RefCountedRegistry<HTMLElement, Options>`. The extensions are unchanged in behavior: create the registry in `init`, bind activation in `build`, dispose in `register`. New core unit tests cover ref-counting, idempotent/guarded disposers, re-registration, deferred setActivate, and the registered-before-activation guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…tionchange listener addRootElementEvents hand-rolled a per-document ref count (rootElementCount) to gate the single shared `selectionchange` listener — add it on the first root element registered against a document, remove it on the last. Replace that bookkeeping with the core createRefCountedRegistry keyed by Document: the registration's disposer is stored alongside the root element's other listener remove-handles, so teardown is uniform and the count can no longer go negative. The per-document `editors` set and `hasShadowEditor` cache that onDocumentSelectionChange reads are unchanged; only the listener's attach/detach ref counting moved to the shared primitive. @lexical/dragon is intentionally left as-is: it coordinates a single shared window `message` listener across separate Lexical builds via a Symbol.for() window expando, which a module-level registry can't provide. Adds a unit test pinning the listener lifecycle (added once for multiple editors, removed after the last detaches, re-added on a fresh mount). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…register() disposers in tests
Two cleanups on the ref-counted registry work:
- Flow stub: the FocusTrap/RovingTabIndex/FocusManager extensions had `{...}`
for Config and `mixed` for Init — opaque and inaccurate. Use the real
inferred types: `ExtensionConfigBase` for the (config-less) Config and
`ManagedRefCountedRegistry<HTMLElement, Options>` for Init, and make the
Flow `ContainerRegistry` alias `RefCountedRegistry<HTMLElement, Options>`
to match the TS source.
- Tests: several extension tests called `register(...)` and ignored the
returned disposer. Hand each disposer to Vitest's `onTestFinished` so the
registration is explicitly torn down after the test rather than relying
solely on editor disposal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…o announcers AriaLiveRegionExtension now mounts its aria-live region via editor.registerRootListener, deriving the owner from the root element's ownerDocument. This makes the region land in the editor's own document (e.g. an iframe-portaled editor) instead of the top-level document, and lets it follow the root across remounts with no manual tracking. An explicit `owner` config still overrides. Because the live handle is now installed lazily when the root mounts, HistoryAnnounceExtension and EditorModeAnnounceExtension read `ref.current` at announce time rather than capturing `ref.current.announce` (which could still be the NOOP handle at register time). Add a `disabled` signal (default false) to both announcer extensions so hosts can toggle announcements at runtime without unregistering. Update the Flow stub and unit tests: announce tests mount a root, the AriaLiveRegion suite gains a foreign-document (iframe) test, and the announcer suites gain disabled-toggle tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
The extension output was `{current: {announce, dispose}}` — a mutable ref
whose `current` was swapped between a live handle and a NOOP sentinel as the
root mounted. That leaked the region's disposal and the `.current` indirection
into the public API, even though every consumer (the React hook, the announcer
extensions, tests) only ever calls `announce`.
Replace it with a stable `{announce}` output. A private `message` signal
(created in `init`) buffers the current text; `build` returns the stable
`announce` that writes to it; `register` tracks the editor's root element via
`watchedSignal` and runs two effects — one owns the region element's lifecycle
(create on root/owner, dispose via effect cleanup), the other mirrors the
message into whatever region is mounted. This drops the handle interface, the
NOOP sentinel, the mutable box, and the announce-time `ref.current` indirection
(the announcers now capture `announce` directly, since it is valid for the
editor's lifetime), while keeping the iframe-correct root binding.
Update the React hook (`output.announce`), the Flow stub, and the test that
read `ref.current.announce`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…iew-ixo58p' into feat/a11y-6006-aa
The arrow cases each recomputed `% items.length` (and the left/up cases carried a `+ items.length` to avoid a negative remainder). Since `currentIdx` is always >= 0, the only out-of-range `nextIdx` is -1, so the wrap can be done once after the switch. The arrow cases now just step +/-1 and Home/End set the ends; a single `(nextIdx + items.length) % items.length` normalizes them all. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
A literal U+200B in source is invisible to anyone reading the code (and easy to mangle). Write it as the `` escape in the announce repeat-toggle and its test assertion so the intent is legible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Three related cleanups now that the extensions own their reactivity directly (the legacy `register*(editor, …primitive)` shape was only for React-plugin compatibility, which does not apply here): - AriaLiveRegion exposes `politeness` and `owner` as runtime-tunable signals via `namedSignals(config)`, matching the announcer extensions. `register` now runs three effects: region lifecycle (recreates on root/owner change), politeness (sets `aria-live` on the mounted region), and message mirroring. Setting `politeness` updates a live region in place; setting `owner` re-mounts it. `createLiveRegion` no longer takes politeness — the effect owns it. - Inline `registerHistoryAnnounce` / `registerEditorModeAnnounce` into the extensions as signal-aware handlers that read `disabled` / message signals at dispatch time. The commands / editable listener register once and pick up config changes without the old effect-driven tear-down-and-re-register. - `registerFocusManager` uses `mergeRegister` with the `registerCommand` expression directly instead of a single-use `removeCommand` variable. Update the Flow stub (`AriaLiveRegion` = namedSignals output + announce), rename the two announcer "re-registers" tests to "reflects message signal changes at runtime" (they no longer re-register), and add a test that toggling the `politeness` signal updates `aria-live` at runtime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…iew-ixo58p' into feat/a11y-6006-aa
…ignal Several extensions defined the same `watchedSignal(() => editor.getRootElement(), … registerRootListener …)`. Add a `RootElementExtension` (alongside `EditorStateExtension` / `WatchEditableExtension`) that exposes the editor's current root element as a reactive `Signal<HTMLElement | null>`, and depend on it instead: - `@lexical/a11y` AriaLiveRegionExtension - playground AutocompleteExtension (`@lexical/dragon` keeps its own window-derived signal — it is a standalone `registerDragonSupport(editor)`, not an extension, so it can't take a dependency.) Also fold in review feedback on the announcers: gate command / editable-listener registration on `disabled` from an `effect` (a disabled announcer now registers nothing) and `peek()` the message signals at dispatch time, instead of reading `disabled.value` / message `.value` inside the handlers (signals should only be read via `peek()` or from an effect). Use concise truthy checks (`if (el)` / `if (!host)`) in the region effects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…iew-ixo58p' into feat/a11y-6006-aa
…eof …> The editor-param helpers (mountRoot / getRegistry) typed their argument as `ReturnType<typeof buildEditorFromExtensions>` (and the shadow builders that forward it). That return type is the exported `LexicalEditorWithDispose`, so import it directly rather than reconstructing it with ReturnType. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…EADMEs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
`TabIndentationExtension` lives only inside `PlaygroundRichTextExtension`, so it is built only when `isRichText` is true, and the editor is rebuilt whenever `isRichText` changes. The peer lookup therefore resolves only in rich-text editors, where `!settings.isRichText` is always false — the assignment could only ever set `disabled` to its default of false and never disabled tab indentation. Remove the no-op block and the now-unused import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…window spy Replace the manual `bubbled` boolean with a `vi.fn()` asserted via `not.toHaveBeenCalled()`, and move the window listener removal from a try/finally to `onTestFinished`, matching the cleanup style used elsewhere in the a11y tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Add a regression test proving the ref-callback cleanup runs when the hook's options change while the same DOM node stays mounted: React re-invokes the changed ref callback (old with null, new with the node), and the shared disposeRef disposes the old registration before re-registering with the new options. If the old registration leaked, its keydown listener would still move focus on ArrowRight; the test asserts it does not. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…allowOutside Fixes from the recall code review: - AriaLiveRegion no longer re-announces the last message when the region is re-created on an editor root remount. The message-mirror effect now reads the region with peek() (still subscribing to the message signal), so it re-runs only when the message changes, not when the region is (re)created — a fresh region starts empty instead of replaying the buffered message into a new element (which a screen reader would speak again with no user action). - AriaLiveRegion's region-lifecycle effect subscribes to the root element only when no explicit `owner` is configured, so a fixed-owner region is no longer torn down and rebuilt on unrelated root churn. - useLexicalFocusTrapRef now accepts the `allowOutside` predicate (held in a ref so an inline lambda doesn't re-create the trap each render), so portaled panels can keep focus from React. Flow stub updated. - Document the ref-counted registry contract: `options` are read only on the activating (first) registration for a key; later registrations share that activation and ignore their options (re-register after full release to change them). This is correct ref-counting, not a bug to "fix" in code. - Clarify in EditorModeAnnounce's `disabled` doc that announcements are transition-based, so re-enabling while already read-only does not re-announce the current mode (by design; a code "fix" would announce on every mount). Add regression tests: no replay on remount, stable custom-owner region across root changes, and allowOutside keeping focus on a matching element. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…hrow - documentSelectionChange: drop the explicit `<Document>` type argument and let it infer from the activation's `(doc: Document)` parameter. The result is the identical `ManagedRefCountedRegistry<Document, void>` (Options falls back to its `void` default, since the activation ignores the second parameter). The a11y registries still pass explicit type args because they defer the activation via setActivate and have nothing to infer from. - createRefCountedRegistry.register: replace the `throw new Error(...)` guard with `invariant(...)`, matching the rest of the package (asserts the condition and narrows the type). codes.json is intentionally left untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Replace the per-entry `count` + per-disposer `released` flag + the `entries.get(key) !== ownEntry` guard with a single `Set` of holder tokens, where each registration's disposer is its own token: - `Set.delete(release)` returning false is the idempotency guard (double release, or a release after teardown / re-registration, is a no-op). - `Set.size === 0` is the "last holder released" check. `dispose()` clears each holder set so a disposer still held by a caller can't re-run the activation cleanup; with that, a stale disposer only ever touches its own now-empty entry's set, so the cross-entry identity guard is unnecessary. Same behavior, less state. All registry / ref-count / a11y tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Have each registration's disposer close over `entries` + `key` and re-resolve the entry, instead of capturing the `Entry` object: - It no longer pins a disposed entry — and its already-run activation cleanup closure (which can capture DOM, e.g. a focus trap's container/listeners) — alive while a caller still holds the returned disposer. After `entries.delete(key)` nothing references the old entry, so it is GC'd. - Staleness handling is now uniform: a stale release (double call, or one after teardown / re-registration) re-resolves the key and finds the live entry (if any) does not contain its token, so `Set.delete` returns false. dispose() no longer needs to clear each holder set. Behavior is unchanged; same tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Hiding `dispose` from the registry output wasn't worth the `init`-then-`build` two-step it required. Collapse it: - One `RefCountedRegistry` interface with `register` + `dispose` (drop `ManagedRefCountedRegistry` and `setActivate`); `createRefCountedRegistry` takes a required `activate` and no longer needs the activate-null invariant. - The three a11y registry extensions now create the registry in `build` (where the editor is available for FocusManager's activation) and dispose it on `register` teardown via `getOutput().dispose()` — no `init` phase. Also fuse the disposer's two guards into one condition: `current && current.holders.delete(release) && current.holders.size === 0`. Update the lexical/a11y Flow stubs and exports; replace the now-obsolete setActivate / register-before-activate tests with a disposer-after-dispose no-op test. Behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Pins the documented "no-op until a region is mounted" contract from the code review: a message announced while no region exists is dropped, not buffered and replayed onto the region when it later mounts (the message effect reads the region via peek(), so it does not re-run on region creation). A message announced after mount is delivered normally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
…ded-extensions Add the new `@lexical/a11y` package (six accessibility extensions) and the new `RootElementExtension` (`@lexical/extension`) to the Included Extensions page, cross-linking the Keyboard Accessibility concepts page for the full contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Completes the extensions audit: NodeSelectionDataSelectedExtension is a user-facing (configurable) extension that was missing from the list. The per-node *ImportExtensions are intentionally omitted — they are internal dependencies of their node extension (their docs say to depend on the node extension directly), not extensions users add themselves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaCKEkFa2dx6fprxCZe7XM
Collaborator
|
@mayrang I think I'm done working with claude to add a little more polish to this, let me know what you think of the current API |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
New
@lexical/a11ypackage shipping framework-agnostic accessibility as@lexical/extensionextensions, plus a small set of React adapter hooks in@lexical/react, a shared ref-counted-registry primitive added to corelexical, and reference adoption across the playground that takes the editor to WCAG 2.1 AA on the keyboard and with a screen reader. Builds on the keyboard accessibility RFC #6006 + KaiPrince's PR #7804 (focus indicator + dropdown focus return — already merged).The accessibility behavior ships as six platform-independent extensions. They are framework-agnostic because
@lexical/extensionis: any host that builds an editor from extensions gets them, React hosts get thin adapter hooks, and non-React hosts (Svelte / Vue / Solid / vanilla) drive the same extension outputs from their own lifecycle. No framework type leaks into@lexical/a11y.Closes #6006.
What ships
@lexical/a11y(new package) — framework-agnostic, depends only onlexical+@lexical/extension+@lexical/utils. Six extensions:AriaLiveRegionExtension— owns a single visually hiddenaria-liveregion for the editor and exposes a stableannounce(message)sink as its output, plus runtime-tunablepoliteness/ownersignals. The region is bound to the editor's root element (viaRootElementExtension), so it is created in the editor's own document — e.g. an iframe-portaled editor — not the top-level one, and follows the root across remounts.announcere-fires on a duplicate message via a trailing zero-width space. WAI-ARIA status message pattern (WCAG 4.1.3). A no-op until a region is mounted (a root element exists, or anowneris configured).HistoryAnnounceExtension— depends onAriaLiveRegionExtension; announces undo / redo ('Undone'/'Redone', configurable) atCOMMAND_PRIORITY_LOW, returningfalseto keep the command chain intact. Adisabledsignal toggles it at runtime.EditorModeAnnounceExtension— same shape, foreditor.setEditable(true|false)transitions ('Editor is editable'/'Editor is read-only'). Transition-based (silent on mount); also runtime-disabled.FocusTrapExtension— output is a reference-counted registry:register(container, {initialFocus, allowOutside}) => dispose. Tab / Shift+Tab fully managed inside the container; a document-levelfocusinlistener pulls escaping focus back; restores the previously-focused element on dispose.initialFocus: 'firstFocusable' | 'container'.RovingTabIndexExtension— registryregister(container, {orientation, itemSelector}) => dispose. WAI-ARIA roving-tabindex: arrow keys + Home / End rove among items, Tab leaves the group as a unit. Items are queried lazily per interaction, so additions / removals are picked up automatically.FocusManagerExtension— registryregister(toolbar, {toolbarItemSelector}) => dispose. APG editor menubar pattern: Alt+F10 inside the editor focuses the toolbar's first roving item, Escape inside the toolbar returns focus to the editor; the editor's selection is preserved across the jump.The three focus extensions share one core primitive: their output is a
ContainerRegistry(reference-counted by container element), so the same container can be driven by more than one caller (or survive a re-entrant registration) without double-wiring, and the activation is torn down only when the last registration is released.@lexical/react(adapters) — four thin hooks; each requires the matching extension in the editor's tree. New in this PR:useLexicalAriaLiveRegion(): (message: string) => void— a stableannounce.useLexicalFocusTrapRef(isActive, initialFocus?, allowOutside?): RefCallback<HTMLElement>— attach withref={trapRef}; registers the node whileisActive, releases on detach / deactivate.allowOutside(held in a ref, so an inline lambda doesn't re-create the trap) lets portaled panels keep focus.useLexicalRovingTabIndexRef(options?): RefCallback<HTMLElement>.useLexicalFocusManagerRef(options?): RefCallback<HTMLElement>.The Ref hooks return a
RefCallbackthat registers / releases the attached node through the extension's reference-counted registry — changing the options re-registers with the new config without leaking the old one.lexical(core) — addscreateRefCountedRegistry<Key, Options>(activate)/RefCountedRegistry(a small first-activate / last-release reference-counting map keyed by object identity).addRootElementEventsnow uses it to share a single documentselectionchangelistener across every editor / root element registered against a document (replacing the hand-rolled count), which also makes the per-document keying correct for shadow-DOM / iframe hosts. Behavior-preserving.@lexical/extension— addsRootElementExtension, exposingeditor.getRootElement()as a reactiveSignal<HTMLElement | null>(consumed byAriaLiveRegionExtension, and by the playground autocomplete that previously hand-rolled the samewatchedSignal).lexical-playground(reference adoption) — wires the adapters across the main toolbar, floating text-format toolbar, modal, and equation node (see "Eleven areas").lexical-website(docs) —concepts/keyboard-accessibility.mddocuments the contract per surface; the new extensions andRootElementExtensionare listed on the Included Extensions page.Using
@lexical/a11yWith
@lexical/extensiondirectly (any host):From React (
LexicalComposer/ extension host):Non-React hosts (Svelte / Vue / Solid / vanilla) read the same extension output and call
registry.register(node, options)/ the returned disposer from their own mount / cleanup hooks — no framework-specific adapter package ships in this PR.Eleven areas covered
Grouped by WAI-ARIA APG pattern.
Operate (keyboard)
role="toolbar"+aria-label— screen readers group the toolbar buttons.useLexicalRovingTabIndexRef.role="toolbar"— mirrors the main toolbar.useLexicalFocusManagerRef.useLexicalFocusTrapRef. Tab / Shift+Tab fully managed; document-levelfocusinrecovery.Modallands initial focus on the dialog container (tabIndex={-1}) so screen readers announce the dialog body viaaria-labelledbybefore any control.Perceive (visual + announce)
EquationNoderole="math"+ dynamicaria-label— cached, re-applied onupdateDOM.EditorModeAnnounceExtensionannounces editable ↔ read-only transitions via aria-live.HistoryAnnounceExtensionannounces the action via aria-live.@media (forced-colors: active)) — toolbar buttons, editor border, and modal:focusoutline pick up systemHighlight/CanvasText; toolbar icons and the logo switch tomask-imagewithbackground-color: CanvasTextso they render as system foreground. Color-meaning icons (font-color, bg-color) keepbackground-imageso the picker color is preserved.prefers-reduced-motion: reduce— collapses transitions / animations to0.01ms.Understand (page contract)
concepts/keyboard-accessibility.mddocuments the contract per surface (toolbar / modal / editor) in one place.Considered but dropped
TabIndentationExtensionreleaseOnEscapeopt-in — verified Lexical's defaulteditor.blur()on Escape already moves Tab focus past the editor on Chrome and Safari (macOS), so the opt-in was redundant for WCAG 2.1.2.LexicalNode.getRole/getAriaLabelcore API — only one consumer (EquationNode); the existing directdom.setAttributepattern already covers it.EquationNodetabindex="0"— Tab insidecontenteditableis consumed byTabIndentationExtension, and the Tab outline overlapped theNodeSelectionoutline. Equation stays announced viarole="math"+aria-label; reach it by caret traversal.Backwards compatibility
@lexical/a11yand the four@lexical/reactadapters is new and unreleased — no existing signatures change.addRootElementEventsnow shares itsselectionchangelistener through the newcreateRefCountedRegistry. The observable behavior (one shared document listener, attached on first root, removed after the last) is unchanged.document-levelfocusinlistener: focus landing outside an active trap is pulled back inside (useallowOutside, or portal panels inside the container). Only one trap should be active at a time.Modalinitial focus is the dialog container, not the first focusable — no outline ring on open (the inner:focusoutline stays); screen reader users hear the dialog title first.Test plan
Before
The playground had no keyboard-accessibility wiring for the toolbar / modal / equation surfaces; no
@lexical/a11ypackage.After
Automated
pnpm tsc/pnpm flow— clean.pnpm vitest run --project unit— passes. New unit tests for the six@lexical/a11yextensions (incl. shadow-DOM variants), the four React adapter hooks, the corecreateRefCountedRegistry(ref-counting / idempotent dispose / re-registration), and theaddRootElementEventsselectionchangeref-count.Chrome / Safari (macOS) — keyboard + DOM
role="toolbar"+aria-label; roving tabindex (Arrow / Home / End, single-step Tab out,tabIndex0 on active / -1 on rest) on both the main and floating toolbars.EquationNodeDOM hasrole="math"+aria-label="Equation: …", updated on edit.Screen readers
aria-label, editable ↔ read-only announcements, Undo / Redo announcements.Visual preferences
:focususe system colors; toolbar icons + logo render as system foreground viamask-image; color-picker icons keep their SVG colors.prefers-reduced-motion: reduce(macOS + DevTools emulation): transitions collapse to ~0.01ms.Mobile — physical keyboard