Skip to content

[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
facebook:mainfrom
mayrang:feat/a11y-6006-aa

Conversation

@mayrang

@mayrang mayrang commented May 29, 2026

Copy link
Copy Markdown
Contributor

Description

New @lexical/a11y package shipping framework-agnostic accessibility as @lexical/extension extensions, plus a small set of React adapter hooks in @lexical/react, a shared ref-counted-registry primitive added to core lexical, 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/extension is: 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 on lexical + @lexical/extension + @lexical/utils. Six extensions:

  • AriaLiveRegionExtension — owns a single visually hidden aria-live region for the editor and exposes a stable announce(message) sink as its output, plus runtime-tunable politeness / owner signals. The region is bound to the editor's root element (via RootElementExtension), 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. announce re-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 an owner is configured).
  • HistoryAnnounceExtension — depends on AriaLiveRegionExtension; announces undo / redo ('Undone' / 'Redone', configurable) at COMMAND_PRIORITY_LOW, returning false to keep the command chain intact. A disabled signal toggles it at runtime.
  • EditorModeAnnounceExtension — same shape, for editor.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-level focusin listener pulls escaping focus back; restores the previously-focused element on dispose. initialFocus: 'firstFocusable' | 'container'.
  • RovingTabIndexExtension — registry register(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 — registry register(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 stable announce.
  • useLexicalFocusTrapRef(isActive, initialFocus?, allowOutside?): RefCallback<HTMLElement> — attach with ref={trapRef}; registers the node while isActive, 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 RefCallback that 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) — adds createRefCountedRegistry<Key, Options>(activate) / RefCountedRegistry (a small first-activate / last-release reference-counting map keyed by object identity). addRootElementEvents now uses it to share a single document selectionchange listener 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 — adds RootElementExtension, exposing editor.getRootElement() as a reactive Signal<HTMLElement | null> (consumed by AriaLiveRegionExtension, and by the playground autocomplete that previously hand-rolled the same watchedSignal).

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.md documents the contract per surface; the new extensions and RootElementExtension are listed on the Included Extensions page.

Using @lexical/a11y

With @lexical/extension directly (any host):

import {
  AriaLiveRegionExtension,
  EditorModeAnnounceExtension,
  FocusTrapExtension,
  HistoryAnnounceExtension,
  RovingTabIndexExtension,
} from '@lexical/a11y';
import {getExtensionDependencyFromEditor} from '@lexical/extension';
import {configExtension, defineExtension} from 'lexical';

const AppExtension = defineExtension({
  dependencies: [
    HistoryAnnounceExtension,
    EditorModeAnnounceExtension,
    RovingTabIndexExtension,
    // localized strings via configExtension if needed:
    // configExtension(HistoryAnnounceExtension, {undone: '…', redone: '…'}),
  ],
  name: '[root]',
});

// When a toolbar mounts, register it; call the returned disposer to release:
const registry = getExtensionDependencyFromEditor(editor, RovingTabIndexExtension).output;
const dispose = registry.register(toolbarEl, {orientation: 'horizontal'});
// later: dispose();

From React (LexicalComposer / extension host):

function Toolbar() {
  const rovingRef = useLexicalRovingTabIndexRef({orientation: 'horizontal'});
  return <div ref={rovingRef} role="toolbar" aria-label="Editor toolbar"></div>;
}

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)

  1. Main toolbar role="toolbar" + aria-label — screen readers group the toolbar buttons.
  2. Main toolbar roving tabindex — ArrowRight / ArrowLeft / Home / End move between buttons; Tab leaves the toolbar in one step. useLexicalRovingTabIndexRef.
  3. FloatingTextFormatToolbar roving tabindex + role="toolbar" — mirrors the main toolbar.
  4. Alt+F10 editor ↔ toolbar jump with Escape return + selection restoreuseLexicalFocusManagerRef.
  5. Modal focus trapuseLexicalFocusTrapRef. Tab / Shift+Tab fully managed; document-level focusin recovery. Modal lands initial focus on the dialog container (tabIndex={-1}) so screen readers announce the dialog body via aria-labelledby before any control.

Perceive (visual + announce)

  1. EquationNode role="math" + dynamic aria-label — cached, re-applied on updateDOM.
  2. Editor mode announceEditorModeAnnounceExtension announces editable ↔ read-only transitions via aria-live.
  3. Undo / Redo announceHistoryAnnounceExtension announces the action via aria-live.
  4. Forced-colors (@media (forced-colors: active)) — toolbar buttons, editor border, and modal :focus outline pick up system Highlight / CanvasText; toolbar icons and the logo switch to mask-image with background-color: CanvasText so they render as system foreground. Color-meaning icons (font-color, bg-color) keep background-image so the picker color is preserved.
  5. prefers-reduced-motion: reduce — collapses transitions / animations to 0.01ms.

Understand (page contract)

  1. Keyboard accessibility concepts pageconcepts/keyboard-accessibility.md documents the contract per surface (toolbar / modal / editor) in one place.

Considered but dropped

  • TabIndentationExtension releaseOnEscape opt-in — verified Lexical's default editor.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 / getAriaLabel core API — only one consumer (EquationNode); the existing direct dom.setAttribute pattern already covers it.
  • EquationNode tabindex="0" — Tab inside contenteditable is consumed by TabIndentationExtension, and the Tab outline overlapped the NodeSelection outline. Equation stays announced via role="math" + aria-label; reach it by caret traversal.

Backwards compatibility

  • Everything in @lexical/a11y and the four @lexical/react adapters is new and unreleased — no existing signatures change.
  • The only core change is internal: addRootElementEvents now shares its selectionchange listener through the new createRefCountedRegistry. The observable behavior (one shared document listener, attached on first root, removed after the last) is unchanged.
  • The focus trap installs a document-level focusin listener: focus landing outside an active trap is pulled back inside (use allowOutside, or portal panels inside the container). Only one trap should be active at a time.
  • Modal initial focus is the dialog container, not the first focusable — no outline ring on open (the inner :focus outline 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/a11y package.

After

Automated

  • pnpm tsc / pnpm flow — clean.
  • pnpm vitest run --project unit — passes. New unit tests for the six @lexical/a11y extensions (incl. shadow-DOM variants), the four React adapter hooks, the core createRefCountedRegistry (ref-counting / idempotent dispose / re-registration), and the addRootElementEvents selectionchange ref-count.
  • prettier + eslint + the build-export audit — clean.

Chrome / Safari (macOS) — keyboard + DOM

  • Toolbar role="toolbar" + aria-label; roving tabindex (Arrow / Home / End, single-step Tab out, tabIndex 0 on active / -1 on rest) on both the main and floating toolbars.
  • Alt+F10 editor → toolbar; Escape returns focus to the editor with the pre-jump selection restored; default Escape blurs the editor and Tab leaves it.
  • Modal focus trap: initial focus on the dialog container; Tab / Shift+Tab cycle within; never escapes; close returns focus to the opener. Safari no longer flashes the URL bar between trap boundaries.
  • EquationNode DOM has role="math" + aria-label="Equation: …", updated on edit.

Screen readers

  • VoiceOver (macOS) and Narrator (Windows 11): toolbar grouping, dialog title announced before controls, equation aria-label, editable ↔ read-only announcements, Undo / Redo announcements.

Visual preferences

  • Windows High Contrast / forced-colors: buttons, border, modal :focus use system colors; toolbar icons + logo render as system foreground via mask-image; color-picker icons keep their SVG colors.
  • prefers-reduced-motion: reduce (macOS + DevTools emulation): transitions collapse to ~0.01ms.

Mobile — physical keyboard

  • iOS Safari / Android Chrome + bluetooth keyboard: Escape blurs the editor; Tab moves to the next focusable.

mayrang added 28 commits May 27, 2026 14:52
…cal default editor.blur() already covers WCAG 2.1.2)
…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).
@vercel

vercel Bot commented May 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Jul 1, 2026 12:03am
lexical-playground Ready Ready Preview, Comment Jul 1, 2026 12:03am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 29, 2026
etrepum and others added 29 commits June 29, 2026 19:18
… 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
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
…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
…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
`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
@etrepum

etrepum commented Jul 1, 2026

Copy link
Copy Markdown
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

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

better support for keyboard accessibility

3 participants