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 51 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 primitives, plus a small set of React adapters in @lexical/react and reference adoption across the playground that takes the editor to WCAG 2.1 AA on the keyboard and with a screen reader. Core lexical is unchanged. Builds on the keyboard accessibility RFC #6006 + KaiPrince's PR #7804 (focus indicator + dropdown focus return — already merged).

The primitives are register* functions returning a cleanup, matching editor.register* listener shape. Each is a self-contained imperative call: registerAriaLiveRegion, registerFocusTrap, registerRovingTabIndex, registerFocusManager, registerHistoryAnnounce, registerEditorModeAnnounce. Same API works from React via the adapters in @lexical/react, from Svelte / Vue / Solid by wiring onMount / onCleanup-style hooks around the same calls, or from vanilla DOM directly. No framework type leaks into @lexical/a11y itself.

Closes #6006.

What ships

@lexical/a11y (new package) — framework-agnostic, depends only on lexical. Six register* primitives:

  • registerAriaLiveRegion(options) => {announce, dispose} — mounts a visually hidden aria-live region (default polite) under the given owner (default document.body). announce re-fires on duplicate messages via a trailing zero-width space. WAI-ARIA status message pattern (WCAG 4.1.3). SSR-safe (returns a no-op handle when document.body is unavailable).
  • registerFocusTrap(container, options) => cleanup — Tab / Shift+Tab fully managed inside container; a document-level focusin listener pulls escaping focus back. initialFocus: 'firstFocusable' | 'container'. Restores the previously-focused element on cleanup.
  • registerRovingTabIndex(container, options) => cleanup — WAI-ARIA roving-tabindex pattern. Arrow keys + Home / End rove among items; Tab leaves the group as a unit. Items are queried lazily on every interaction, so additions / removals during the group's lifetime are picked up without extra wiring. orientation: 'horizontal' | 'vertical' | 'both'.
  • registerFocusManager(editor, toolbar, options) => cleanup — 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 (via editor.focus() + a rootElement.focus() fallback). The editor's last selection is preserved across the jump.
  • registerHistoryAnnounce(editor, announce, options) => cleanup — fires announce('Undone' / 'Redone') at COMMAND_PRIORITY_LOW, returns false to keep the command chain intact.
  • registerEditorModeAnnounce(editor, announce, options) => cleanupeditor.registerEditableListener that fires announce('Editor is editable' / 'Editor is read-only').

@lexical/react (React adapters) — thin useEffect / plugin wrappers around the primitives. Existing signatures preserved — BC 0:

  • useLexicalAriaLiveRegion(options) => announce
  • useLexicalFocusTrap(containerRef, isActive, initialFocus)
  • useLexicalRovingTabIndex(containerRef, options)
  • useLexicalFocusManager(editor, toolbarRef, options)
  • HistoryAnnouncePlugin({messages})
  • EditorModeAnnouncePlugin({messages})

@lexical/a11y extensions — six platform-independent extensions wrap the register* primitives for hosts using @lexical/extension. The announce extensions own / depend on a shared live-region sink; the DOM-target extensions (FocusTrap / RovingTabIndex / FocusManager) take their host element via a namedSignals config field — null keeps the extension inert and a non-null value re-registers through effect, so hosts flip the ref as modals or toolbars mount without manual lifecycle wiring.

  • AriaLiveRegionExtension — owns the live region; build returns a stable {current: NOOP_HANDLE} ref, register swaps in the live AriaLiveRegionHandle so the "build must not require cleanup" contract holds.
  • HistoryAnnounceExtension — depends on AriaLiveRegionExtension; messages on namedSignals, register wraps registerHistoryAnnounce in effect.
  • EditorModeAnnounceExtension — same shape, for setEditable transitions.
  • FocusTrapExtension — config container: HTMLElement | null + initialFocus; flipping the signal as the modal mounts / unmounts activates / deactivates the trap.
  • RovingTabIndexExtension — config container, orientation, itemSelector; signal-driven activation; runtime orientation / selector changes re-register through effect.
  • FocusManagerExtension — config toolbar: HTMLElement | null + toolbarItemSelector; signal-driven Alt+F10 / Escape wiring.

All six React adapters (the two *Plugin components and the four useLexical* hooks) are new in this PR and remain the recommended React path for LexicalComposer hosts. Their JSDoc carries a single cross-reference line pointing at the matching @lexical/a11y extension for hosts that prefer the @lexical/extension framework path. No @deprecated mark — the React adapters and the extensions are different entry points for different host shapes, not a migration.

lexical-playground (reference adoption) — wires the adapters across the toolbar, modal, and equation node (see "Eleven areas" below).

lexical-website (docs)concepts/keyboard-accessibility.md documents the contract per surface.

Using @lexical/a11y from other frameworks

The primitives expose only HTMLElement / LexicalEditor / plain functions, so they wire into any framework's lifecycle:

<!-- Svelte -->
<script>
  import {registerAriaLiveRegion} from '@lexical/a11y';
  import {onMount, onDestroy} from 'svelte';
  let handle;
  onMount(() => { handle = registerAriaLiveRegion(); });
  onDestroy(() => { handle?.dispose(); });
</script>
<!-- Vue 3 Composition API -->
<script setup>
import {registerAriaLiveRegion} from '@lexical/a11y';
import {onMounted, onBeforeUnmount} from 'vue';
let handle;
onMounted(() => { handle = registerAriaLiveRegion(); });
onBeforeUnmount(() => { handle?.dispose(); });
</script>
// Solid
import {registerAriaLiveRegion} from '@lexical/a11y';
import {onCleanup} from 'solid-js';
const handle = registerAriaLiveRegion();
onCleanup(() => handle.dispose());
// Vanilla
import {registerAriaLiveRegion} from '@lexical/a11y';
const handle = registerAriaLiveRegion();
handle.announce('Hello');
// later: handle.dispose();

No Svelte / Vue / Solid sibling adapter packages ship in this PR — consumers wire the lifecycle directly. If the demand surfaces those can be added as separate packages later.

Using @lexical/a11y with @lexical/extension

For hosts using @lexical/extension, the announce extensions wire declaratively (AriaLiveRegionExtension owns the sink; the announce extensions take it as a dependency); the DOM-target extensions (FocusTrap / RovingTabIndex / FocusManager) take their host element via the dependency output signal so the host flips it as refs mount / unmount.

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

defineExtension({
  dependencies: [
    HistoryAnnounceExtension,
    EditorModeAnnounceExtension,
    FocusTrapExtension,
    RovingTabIndexExtension,
    FocusManagerExtension,
    // localized strings via configExtension if needed:
    // configExtension(HistoryAnnounceExtension, {undone: '...', redone: '...'}),
  ],
  name: '[root]',
});

// In the host (e.g. modal open / toolbar mount):
// const {container} = getExtensionDependencyFromEditor(
//   editor,
//   FocusTrapExtension,
// ).output;
// container.value = modalRef.current; // null on close

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 a single step. Wired via useLexicalRovingTabIndexregisterRovingTabIndex.
  3. FloatingTextFormatToolbar roving tabindex + role="toolbar" — mirrors the main toolbar.
  4. Alt+F10 editor ↔ toolbar jump with Escape return + selection restoreuseLexicalFocusManagerregisterFocusManager.
  5. Modal focus trapuseLexicalFocusTrapregisterFocusTrap. 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 announceEditorModeAnnouncePlugin announces editable ↔ read-only transitions via aria-live.
  3. Undo / Redo announceHistoryAnnouncePlugin 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 (block type, align, format, insert dropdown, undo / redo, link, font size steppers) and the lexical 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 pagepackages/lexical-website/docs/concepts/keyboard-accessibility.md documents the contract per surface (toolbar / modal / editor) so plugin authors get the rules in one place.

Considered but dropped

  • TabIndentationExtension releaseOnEscape opt-in (commit 90e26d316 add → 8f290b153 revert). Verified that Lexical's default editor.blur() on Escape already moves Tab focus past the editor on Chrome and Safari (macOS, normal window), so the opt-in was redundant for WCAG 2.1.2.
  • LexicalNode.getRole / getAriaLabel core API (3e95aa529 add → 304965b77 revert). Only one consumer (EquationNode), and the existing direct dom.setAttribute pattern (e.g. LexicalListItemNode, LexicalMenu) already covers it. Moved EquationNode to direct setAttribute.
  • EquationNode tabindex="0" (tried → reverted, not committed). The goal was to let screen reader users reach the equation directly with Tab rather than via caret traversal, but the host span sits inside contenteditable where Tab is consumed by TabIndentationExtension, the Tab focus outline overlaps the existing NodeSelection outline, and Tab focus would need NodeSelection sync to behave consistently. Equation stays announced via role="math" + aria-label; reach it by caret traversal.

Backwards compatibility

  • All existing React hook / plugin signatures preserved. The React adapters now delegate to @lexical/a11y's register* primitives internally, but the hooks themselves return the same values and accept the same arguments.
  • useFocusTrap (renamed earlier in this branch to useLexicalFocusTrap) uses full Tab management — every Tab / Shift+Tab is preventDefault'd and the helper advances focus by index. Tradeoff: contentEditable Tab indent doesn't work inside a focus-trapped container. The helper is intended for modal dialogs (the only caller in this repo is Modal.tsx).
  • The focus trap installs a document-level focusin listener. Any focus landing outside the container while the trap is active is pulled back inside. Descendants that mount into a portal outside the container (autocomplete panels, tooltips, toasts that auto-focus themselves) get yanked back as soon as they take focus. Portal them inside the container, or skip the helper for those dialogs. Only one trap at a time — two active traps install competing focusin listeners and fight over focus.
  • Modal initial focus is the dialog container, not the first focusable. Visual change: no outline ring on open (the container outline is explicitly hidden; the inner :focus outline stays). Screen reader users hear the dialog title before any control.
  • The six React adapters (two *Plugin components + four useLexical* hooks) are all new in this PR. Their JSDoc carries a one-line cross-reference to the matching @lexical/a11y extension for hosts that prefer the @lexical/extension framework path. No @deprecated mark — adapters and extensions are different entry points for different host shapes, not a migration.

Test plan

Automated

  • pnpm tsc --noEmit -p tsconfig.json — clean.
  • pnpm flow check — clean.
  • pnpm vitest run --project unit — passes. @lexical/a11y adds 6 unit tests for registerAriaLiveRegion (mount / politeness / owner / announce / duplicate-message zero-width-space / dispose idempotency); existing React hook / plugin tests pass unchanged through the wrappers.
  • prettier + eslint — clean.

Chrome (macOS) — keyboard + DOM

  • Toolbar grouping — load http://localhost:3000/, Tab into the editor toolbar; DevTools shows <div class="toolbar" role="toolbar" aria-label="Editor toolbar"> on the wrapper.
  • Roving tabindex (main toolbar) — focus the first toolbar button; ArrowRight / ArrowLeft move between buttons; Home / End jump to first / last; Tab leaves the toolbar in a single step and lands in the editor; document.activeElement.tabIndex is 0 on the active item and -1 on the rest.
  • Roving tabindex (FloatingTextFormatToolbar) — select editor text; the floating toolbar appears; the wrapper has role="toolbar" + aria-label="Floating text format toolbar"; ArrowRight / ArrowLeft move between its buttons.
  • Alt+F10 editor → toolbar — click into the editor, type a character, press Alt+F10; focus jumps to the toolbar's first roving item; document.activeElement is the Undo button.
  • Escape return + selection restore — from the Alt+F10 state press Escape; focus returns to the editor; the cursor sits at the exact pre-Alt+F10 position; typing continues from that position.
  • Default Escape — from a normal editor cursor press Escape; the editor loses focus (no cursor / outline); Tab then moves focus to the next focusable on the page (Chrome lands on the Import button; Safari restores focus to the upstream toolbar — both leave the editor, the destination is a browser focus-restoration difference), not back into the editor.
  • Modal focus trap — open Insert Image / Insert Equation via the toolbar; initial focus is the dialog container (no outline ring); Tab cycles forward through the modal's focusables; Shift+Tab cycles in reverse; Tab never escapes the modal; clicking close returns focus to the opener toolbar button.
  • Shortcuts help dialog — toolbar ? opens the shortcuts modal; Escape and close both dismiss it.
  • EquationNode role + aria-label — insert an equation with x^2 + y^2; its DOM has <span class="editor-equation" role="math" aria-label="Equation: x^2 + y^2">; editing to a + b updates aria-label to "Equation: a + b".

Safari (macOS, normal window)

  • Parity with Chrome — same flows behave identically; specifically Tab inside a modal no longer routes through the browser chrome (URL-bar flash) between trap boundaries.

macOS VoiceOver (Cmd+F5)

  • Toolbar announce — Tab into the toolbar; reads "Editor toolbar, toolbar, Undo, button" (or close equivalent).
  • Modal announce — open Insert Image; reads "Insert Image, dialog" (aria-modal + aria-labelledby).
  • EquationNode math announce — cursor on the equation; reads "Equation: x^2 + y^2" or "math". Safari speaks the aria-label; Chrome announces "math" without the body text — browser/SR gap on role="math", not a code-side issue.
  • Editor mode announce — toggle Read Only via Settings; aria-live reads "Editor is read-only" then "Editor is editable" on the next toggle.
  • Undo / Redo announce — type, Cmd+Z reads "Undone"; Cmd+Shift+Z reads "Redone".

Windows 11 (Chrome) — Narrator + High Contrast

  • Narrator toolbar grouping — Win+Ctrl+Enter on; Tab into the toolbar; reads the toolbar label and each button name + role + state (e.g. "Undo button").
  • Modal announce — open Insert Image / Insert Equation; reads dialog title via aria-labelledby before any inner control.
  • EquationNode math announce — caret on the equation; reads the equation's aria-label.
  • Editor mode + Undo / Redo announce — toggling read-only and Ctrl+Z / Ctrl+Y emit the same aria-live messages as VoiceOver.
  • Roving tabindex + Alt+F10 + Escape return — ArrowRight / ArrowLeft / Home / End move between toolbar items; Alt+F10 jumps from the editor into the toolbar; Escape returns to the prior selection.
  • High Contrast Black / White — Settings → Accessibility → Contrast themes; toolbar buttons, editor border, modal :focus outline use system colors; toolbar icons and the lexical logo render as system foreground via mask-image; font-color / bg-color picker icons keep their SVG colors.

prefers-reduced-motion

  • macOS Reduce Motion — System Settings → Accessibility → Display → Reduce Motion ON; in the playground getComputedStyle(document.querySelector('.toolbar-item')).transitionDuration is 1e-05s; Modal / dropdown open and close appear instantaneous.
  • Chrome DevTools emulate — Rendering → "Emulate CSS prefers-reduced-motion: reduce"; same observable result as the macOS path.

Mobile — physical keyboard

  • iOS Safari + physical keyboard — open the playground on an iPad with a bluetooth keyboard; Escape blurs the editor; Tab moves focus to the page's next focusable.
  • Android Chrome + physical keyboard — same as iOS.

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 Jun 24, 2026 12:11pm
lexical-playground Ready Ready Preview, Comment Jun 24, 2026 12:11pm

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

@potatowagon potatowagon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.

Assessment: LGTM — Excellent accessibility foundation 🎉

This is a comprehensive, well-architected PR that introduces @lexical/a11y as a new first-party package providing framework-agnostic accessibility helpers with React wrappers in @lexical/react. The scope is large (~5200 lines across ~50 files) but well-organized.

What I checked:

1. Core primitives (packages/lexical-a11y/src/index.ts — 781 lines):

  • registerAriaLiveRegion — SSR-safe (null body fallback → NOOP_HANDLE), visually hidden styling correct, zero-width-space dedup for repeated messages so screen readers re-announce. Dispose is idempotent. ✅
  • registerFocusTrap — Full Tab management (not boundary-only), focusin safety net for escaped focus, previouslyFocused restoration on dispose, handles empty containers gracefully. The isHTMLElement guard on previouslyFocused prevents SVG.focus() crashes. ✅
  • registerRovingTabIndex — Lazy item querying (additions/removals picked up automatically), wraparound navigation, Home/End support, orientation-aware. ✅
  • registerFocusManager — Alt+F10 jump, Escape return with stopPropagation (prevents modal close interference), editor.focus() + rootElement.focus() for test env compat. ✅
  • registerHistoryAnnounce / registerEditorModeAnnounce — COMMAND_PRIORITY_LOW, returns false (keeps chain intact). ✅

2. Extension API integration:

  • All extensions use defineExtension + effect() + namedSignals pattern correctly. Config defaults via safeCast. The AriaLiveRegionRef pattern (ref object swapped in register, restored to NOOP on dispose) is a clean solution for the "build must not require cleanup" contract. ✅

3. React hooks (@lexical/react):

  • useLexicalAriaLiveRegion — Stable announce callback via useCallback+useRef pattern. Proper cleanup on unmount. ✅
  • useLexicalFocusTrap — Effect depends on [isActive, containerRef, initialFocus]. Cleanup restores focus. ✅
  • useLexicalRovingTabIndex / useLexicalFocusManager — Clean ref-based wiring with null guards. ✅

4. Playground integration:

  • Modal gets aria-modal, aria-labelledby (connected via useId), focus trap. ✅
  • Toolbar gets role="toolbar", aria-label, roving tab index, Alt+F10 focus manager. ✅
  • Floating toolbar gets role="toolbar", aria-label, roving tab index. ✅
  • EquationNode gains role="math" + aria-label with dynamic updateDOM. ✅
  • Icons migrated from background-image to CSS mask-image for forced-colors/high-contrast support. ✅
  • @media (prefers-reduced-motion) and @media (forced-colors: active) blocks added. ✅
  • Logo changed from <img> to <span class="logo"> with background/mask for high-contrast. ✅
  • Keyboard shortcuts help dialog (? button) added to toolbar. ✅

5. Test coverage:

  • 6 unit test files for the core package (AriaLiveRegion, EditorModeAnnounce, FocusManager, FocusTrap, HistoryAnnounce, RovingTabIndex extensions + registerAriaLiveRegion standalone)
  • 6 React hook test files (useLexicalAriaLiveRegion, useLexicalFocusManager, useLexicalFocusTrap, useLexicalRovingTabIndex, + plugin tests)
  • 1 playground unit test (EquationNode ARIA)
  • Existing e2e tests updated for new ARIA attributes. Comprehensive.

6. Documentation:

  • New keyboard-accessibility.md concept page added to the website with Tab/Escape/toolbar contracts, modal trap docs, SR announcement docs, and component ARIA quick reference. Added to sidebars.js. ✅

7. www compatibility assessment:

  • New package @lexical/a11y — additive, no www breakage.
  • New exports from @lexical/react — additive, no removed/renamed exports.
  • EquationNode.updateDOM signature change (prevNode: thisprevNode: this, dom: HTMLElement) — matches the base class signature already expected by Lexical core. Safe.
  • Logo <img><span class="logo"> in playground only — no www impact (playground-only).
  • CSS mask-image migration — playground-only styles. No www compat concern.
  • No removed exports, no renamed modules, no changed defaults on existing APIs.
  • Flow types provided for all new exports. ✅

8. Potential edge cases noted (non-blocking):

  • The focus trap doc correctly warns about portal descendants being yanked back and single-trap limitation. Good disclosure.
  • focusin safety net could conflict with React portals that are logically inside the dialog but DOM-wise outside the container — but the doc acknowledges this explicitly.

Minor observations (non-blocking):

  • The SHORTCUTS import in ShortcutsHelpDialog.tsx assumes the shortcuts object is stable — looks correct given it is a const.
  • The updateDOM change in EquationNode now returns false when only equation text changes (previously always returned the inline-check result). This is correct — it updates the DOM attribute in-place rather than forcing a full re-render.

CI status:

CLA ✅, Vercel ✅, core browser tests ✅, build-ubuntu ✅. Full matrix still pending (recently pushed). No failures.

Verdict:

This is high-quality work that significantly improves Lexical's accessibility story. Well-documented, thoroughly tested, follows established extension patterns, and is additive-only (no regressions). Ready to approve once CI completes green.

…gin pair, tighten test cleanup

Code review follow-up. All six React adapters (two *Plugin components +
four useLexical* hooks) are new in this PR — the @deprecated mark on
HistoryAnnouncePlugin / EditorModeAnnouncePlugin contradicts the
HorizontalRulePlugin precedent (which is an existing plugin migrated
into the @lexical/extension layer, not a same-PR sibling). The plugins
now carry the same one-line cross-reference as the hooks instead of
@deprecated.

Test cleanup nits from the review:
- Replace dead editor.getEditorState() in the two inert tests with
  void editor;
- Use document.body.replaceChildren() in the afterEach hooks so future
  contributors aren't bound to <div> as the only mounted element type.

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the plugins that were ported to extensions can be dropped, no need to maintain two ways to do it

…gin / EditorModeAnnouncePlugin

Per review — these two React adapters were introduced in this same PR
alongside the extension equivalents and never shipped publicly. With
HistoryAnnounceExtension / EditorModeAnnounceExtension covering the
@lexical/extension hosts and registerHistoryAnnounce /
registerEditorModeAnnounce covering vanilla integrations, the React
plugin pair adds no surface that isn't already reachable. Removing
before merge.

- Delete LexicalHistoryAnnouncePlugin.ts / LexicalEditorModeAnnouncePlugin.ts
  (src, flow, unit tests) and the four package.json export entries.
- Drop the matching paths from lexical-devtools / lexical-website
  tsconfigs.
- Update the JSDoc on registerHistoryAnnounce /
  registerEditorModeAnnounce to point at the extension equivalents
  instead of the deleted plugins; drop the "Replaces the React-only
  *Plugin" line from the extension JSDoc.
- Update keyboard-accessibility.md to describe the extension trio
  (AriaLiveRegion + History + EditorMode) instead of the plugin pair.
- Move HistoryAnnounceExtension / EditorModeAnnounceExtension into
  the playground AppExtension dependencies and remove the JSX
  <*AnnouncePlugin /> instances from Editor.tsx.
@mayrang

mayrang commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Done. Both plugins (src, flow, tests, package.json exports, devtools/website tsconfig path maps) gone. Playground wires HistoryAnnounceExtension / EditorModeAnnounceExtension through AppExtension, and the JSDoc / keyboard-accessibility doc point at the extensions instead.

67f0ce7.

@etrepum

etrepum commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

There are some conflicts to resolve with main after other PRs landed

Resolved adjacent additions in .flowconfig, tsconfig.json, tsconfig.test.json, packages/lexical-devtools/tsconfig.json, packages/lexical-website/tsconfig.json, and packages/lexical-react/package.json — `useLexicalRovingTabIndex` (this PR) lands alongside `useLexicalSlotRef` (facebook#8603).

Verification: pnpm tsc / pnpm flow / @lexical/a11y + useLexicalRovingTabIndex/FocusTrap/FocusManager vitest suites all green.
@mayrang

mayrang commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Resolved — merged latest main (6b8c23e3). Adjacent additions next to useLexicalSlotRef (#8603) in path maps + lexical-react/package.json exports; no source conflicts.

@etrepum

etrepum commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Now that all of the big PRs have landed, I'm happy to give this a closer look after the remaining conflicts are resolved

Resolved conflicts:
- FloatingTextFormatToolbarPlugin/index.tsx: kept useLexicalRovingTabIndex import, removed duplicate mergeRegister import (now in lexical core)
- sidebars.js: kept both keyboard-accessibility and shadow-dom entries
@mayrang

mayrang commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Conflicts resolved, merged latest main.

@etrepum

etrepum commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Here's a claude review of the current state (not human verified). In addition to these the flow syntax has changed a bit since this PR started, for hooks the preferred syntax is to use hook instead of function, e.g.

declare export hook useLexicalSubscription<T>(
  subscription: (editor: LexicalEditor) => LexicalSubscription<T>,
): T;

I believe that flow uses these to throw when hooks are called outside of a component or hook.

Code review — @lexical/a11y (re-review after main merge)

Re-reviewed at the latest head. The update since the last pass is only a main merge — no @lexical/a11y feature code changed, so the findings below still apply (line numbers verified against the current head). Several of the most serious are keyboard/AT regressions introduced by an accessibility PR, plus a couple that will fail CI / break Flow typing.

🚫 Blocking (fails CI / ships broken public types)

1. Orphaned tsconfig / .flowconfig entries for plugins that no longer exist
tsconfig.json:116,134 · tsconfig.test.json:106,124 · .flowconfig:72,78
@lexical/react/LexicalEditorModeAnnouncePlugin and …/LexicalHistoryAnnouncePlugin path mappings remain (leftovers from the dropped plugin pair), but the source/flow files don't exist and neither is in lexical-react/package.json exports. update-tsconfig.mjs / update-flowconfig.mjs derive paths from package.json exports, so the "configs up to date" CI check will regenerate without these entries → diff → red CI. The mappings also resolve to non-existent files.

2. Flow stubs declare the wrong hook names
packages/lexical-react/flow/useLexicalFocusManager.js.flow:16 (and the three sibling stubs)
The stubs declare export function useFocusManager / useFocusTrap / useRovingTabIndex / useAriaLiveRegion, but the TS modules and package.json export the useLexical* names. A Flow consumer importing useLexicalFocusManager from @lexical/react/useLexicalFocusManager gets no matching export from the mapped .flow file — the stubs fail at their one job.

3. flow/LexicalA11y.js.flow omits the exported AriaLiveRegionRef type
packages/lexical-a11y/flow/LexicalA11y.js.flow
Every exported symbol is declared except AriaLiveRegionRef (exported from src/index.ts:554 — the output type of AriaLiveRegionExtension that HistoryAnnounce/EditorModeAnnounce depend on). TS and Flow public surfaces disagree.

🔴 High (functional a11y / keyboard regressions)

4. Focus trap pulls focus out of portaled menus opened inside a modal
packages/lexical-a11y/src/index.ts:231 · packages/lexical-playground/src/ui/Modal.tsx:33
registerFocusTrap's document-level focusin handler refocuses the container whenever focus lands outside it. The toolbar's Insert Columns Layout dialog (a trapped Modal) renders InsertLayoutDialog → ui/DropDown, whose menu createPortals to document.body (outside .Modal__modal) and focuses an item — so the trap immediately yanks focus back and the dropdown can't be operated by keyboard. The handler also has no single-trap guard, so two stacked dialogs install competing focusin listeners that fight over focus.

5. Roving tabindex is never restored on dispose
packages/lexical-a11y/src/index.ts:376
registerRovingTabIndex sets every item to tabIndex = -1 except one, but its cleanup only removes the keydown listener. When the container signal flips to null, the effect re-runs, or the editor disposes, the non-active buttons stay at tabIndex="-1" — permanently dropping out of the Tab order until a full re-render.

6. Escape is hijacked for any toolbar descendant, including the font-size input
packages/lexical-a11y/src/index.ts:432 · packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx:157
registerFocusManager's toolbar keydown handler treats any Escape reaching the toolbar as "return to editor" (no check that focus is on a roving item). The FontSize <input> is a direct .toolbar child, so pressing Escape to cancel an edit runs preventDefault + stopPropagation + editor.focus(), yanking focus into the editor and swallowing the key.

7. Alt+F10 lands on the first toolbar button, not the active roving item
packages/lexical-a11y/src/index.ts:421
toolbar.querySelector(selector) returns the first matching element in document order (Undo), regardless of which item carries tabindex="0". This contradicts the JSDoc ("the active roving item naturally receives focus") and leaves focus on a tabindex="-1" element while a different item is the roving 0 — a focus/tabindex desync.

8. Roving tabindex isn't re-synced when toolbar contents change
packages/lexical-a11y/src/index.ts:311
init() runs once; the React hook's deps don't change when the toolbar conditionally mounts/unmounts buttons (e.g. moving the selection into a code block). New <button>s mount with React's default tabIndex=0 alongside the existing roving 0, so the role="toolbar" group has multiple Tab stops until an arrow key fires.

9. Docs reference hook names/import paths that don't exist
packages/lexical-website/docs/concepts/keyboard-accessibility.md:30,36,40,44
The page documents useFocusManager / useFocusTrap / useRovingTabIndex / useAriaLiveRegion at @lexical/react/useFocusManager etc., but the exports are the useLexical* subpaths. Copy-pasting any documented import fails to resolve.

🟡 Medium

10. useLexicalAriaLiveRegion re-creates the region on owner/politeness change, dropping announcements
packages/lexical-react/src/useLexicalAriaLiveRegion.ts:31
owner is in the effect deps; an identity-unstable owner tears down and rebuilds the region every render. A message announced just before a re-render is removed with the old region, and the fresh empty region also defeats the repeated-message zero-width-space toggle.

11. Focus isn't restored when the opener left focus on <body>
packages/lexical-a11y/src/index.ts:183
previouslyFocused is captured from document.activeElement, which is <body> (→ null) when nothing is focused (e.g. Safari, where buttons don't focus on click). On dispose the !== null guard is false, so focus is never returned and lands on <body>.

12. EquationNode.exportDOM drops role/aria-label
packages/lexical-playground/src/nodes/EquationNode.tsx:80
createDOM/updateDOM set role="math" + aria-label, but exportDOM only emits the data-lexical-* attributes, so copied/exported HTML loses the equation announcement on round-trip.

13. initialFocus === 'container' guard is a tautology
packages/lexical-a11y/src/index.ts:188
container.tabIndex >= -1 is always true (tabIndex is never < -1), so it never verifies the container is focusable. A container without a tabindex attribute takes the container.focus() branch, which the browser ignores — focus stays outside the trap. (Masked in the playground because Modal sets tabIndex={-1}, but the published primitive fails for callers who follow initialFocus:'container' without the attribute.)

🧹 Cleanup

14. Hand-rolled teardown instead of mergeRegister
packages/lexical-a11y/src/index.ts:484
registerHistoryAnnounce / registerFocusManager / registerFocusTrap build composite disposers by hand. Core already exports mergeRegister (lexical / @lexical/utils), which also still runs the remaining teardowns if one throws.

15. Duplicated focusable-element selector
packages/lexical-a11y/src/index.ts:122
FOCUSABLE_SELECTOR / getFocusableElements re-implement selection that the playground already ships in src/utils/focusUtils.ts (used by DropDown, ColorPicker, ToolbarPlugin, fontSize), and the two selectors already disagree on [tabindex="-1"]/disabled handling. @lexical/a11y is the natural home — consolidate to one definition.


Checked and discarded (not issues)
  • Roving's :scope > button selector does cover the dropdown triggers — DropDown returns a fragment, so its <button> is a direct DOM child of .toolbar.
  • The announce zero-width-space toggle is correct across repeated identical calls (it alternates, never grows).
  • The HistoryAnnounce/EditorModeAnnounceAriaLiveRegion ref-swap is safe today: dependency register() runs before dependents, and announce no-ops after dispose.

…board accessibility helpers

Fix focus trap portal escape via allowOutside callback, restore roving
tabindex on dispose, guard Escape to roving items only, prefer active
roving item on Alt+F10, fix container.tabIndex tautology, add
mergeRegister cleanup, fix EquationNode exportDOM ARIA, update Flow
stubs to hook syntax, remove orphaned config entries, fix doc hook names.
…iveRegionRef mutability, FocusTrapInitialFocus export)
@mayrang

mayrang commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Switched Flow stubs to declare export hook and addressed 11 of the 15 findings.

The applied ones: orphaned config entries removed (#1), flow stub names fixed to useLexical* (#2), AriaLiveRegionRef added to flow — also caught that the extension output type parameter was still AriaLiveRegionHandle instead of AriaLiveRegionRef (#3), allowOutside callback on FocusTrapOptions for portaled menus (#4), roving dispose restores tabIndex=0 (#5), Escape now checks against actual roving items via querySelectorAll + contains so the font-size input and other non-roving descendants pass through (#6), Alt+F10 tries [tabindex="0"] first (#7), docs hook names fixed (#9), exportDOM gets role="math" + aria-label (#12), hasAttribute('tabindex') replaces the tautological tabIndex >= -1 (#13), mergeRegister for cleanup (#14). Also added a missing FocusTrapInitialFocus re-export in the focus trap flow stub.

On the ones I left:

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