@lexical/a11y 0.0.0-bootstrap.0 → 0.46.1-nightly.20260706.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/LexicalA11y.dev.js +533 -0
- package/dist/LexicalA11y.dev.mjs +526 -0
- package/dist/LexicalA11y.js +11 -0
- package/dist/LexicalA11y.js.flow +100 -0
- package/dist/LexicalA11y.mjs +17 -0
- package/dist/LexicalA11y.node.mjs +15 -0
- package/dist/LexicalA11y.prod.js +9 -0
- package/dist/LexicalA11y.prod.mjs +9 -0
- package/dist/index.d.ts +167 -0
- package/dist/typescript-too-old.d.ts +18 -0
- package/package.json +70 -6
- package/src/index.ts +726 -0
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@lexical/a11y`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://lexical.dev/docs/api/modules/lexical_a11y)
|
|
4
|
+
|
|
5
|
+
Framework-agnostic accessibility helpers for Lexical: ARIA live regions, focus management, roving tab index, and focus trap. Usable from React, Svelte, Vue, Solid, or vanilla DOM. React-specific wrappers around the same primitives ship from `@lexical/react`.
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
var extension = require('@lexical/extension');
|
|
12
|
+
var lexical = require('lexical');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
16
|
+
*
|
|
17
|
+
* This source code is licensed under the MIT license found in the
|
|
18
|
+
* LICENSE file in the root directory of this source tree.
|
|
19
|
+
*
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function applyVisuallyHidden(el) {
|
|
23
|
+
const style = el.style;
|
|
24
|
+
style.border = '0';
|
|
25
|
+
style.clip = 'rect(0 0 0 0)';
|
|
26
|
+
style.height = '1px';
|
|
27
|
+
style.margin = '-1px';
|
|
28
|
+
style.overflow = 'hidden';
|
|
29
|
+
style.padding = '0';
|
|
30
|
+
style.position = 'absolute';
|
|
31
|
+
style.whiteSpace = 'nowrap';
|
|
32
|
+
style.width = '1px';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates a visually hidden live-region element as a child of `owner` and
|
|
37
|
+
* returns it. WAI-ARIA status message pattern (WCAG 4.1.3). The caller owns
|
|
38
|
+
* the element's lifetime (removal) and applies the `aria-live` politeness and
|
|
39
|
+
* `textContent` reactively.
|
|
40
|
+
*/
|
|
41
|
+
function createLiveRegion(owner) {
|
|
42
|
+
const region = owner.ownerDocument.createElement('div');
|
|
43
|
+
region.setAttribute('aria-atomic', 'true');
|
|
44
|
+
region.setAttribute('role', 'status');
|
|
45
|
+
applyVisuallyHidden(region);
|
|
46
|
+
owner.appendChild(region);
|
|
47
|
+
return region;
|
|
48
|
+
}
|
|
49
|
+
const FOCUSABLE_SELECTOR = ['a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]'].join(',');
|
|
50
|
+
function getFocusableElements(container) {
|
|
51
|
+
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
52
|
+
}
|
|
53
|
+
function containsComposed(container, target) {
|
|
54
|
+
let current = target;
|
|
55
|
+
while (current !== null) {
|
|
56
|
+
if (current === container) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (lexical.isDOMShadowRoot(current)) {
|
|
60
|
+
current = current.host;
|
|
61
|
+
} else {
|
|
62
|
+
current = current.parentNode;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Traps Tab / Shift+Tab focus inside `container` and restores focus to
|
|
69
|
+
* the previously-focused element when the returned dispose runs.
|
|
70
|
+
* Intended for modal dialogs and other transient overlays.
|
|
71
|
+
*
|
|
72
|
+
* While active, *any* focus that lands outside the container is pulled
|
|
73
|
+
* back inside via a document-level `focusin` listener. This recovers
|
|
74
|
+
* from Safari's default Tab routing through the browser chrome, but it
|
|
75
|
+
* also means descendants that mount into a portal outside `container`
|
|
76
|
+
* (autocomplete panels, tooltips, toasts that auto-focus themselves)
|
|
77
|
+
* will be yanked back as soon as they take focus. Portal them inside
|
|
78
|
+
* `container`, or skip this helper for those dialogs. The pull-back
|
|
79
|
+
* always lands on the first focusable descendant (or the container as a
|
|
80
|
+
* fallback) — `initialFocus` only applies to the activation-time
|
|
81
|
+
* landing, not to subsequent escape recoveries. Only one trap should be
|
|
82
|
+
* mounted at a time — two active traps install competing document-level
|
|
83
|
+
* `focusin` listeners and will fight over focus.
|
|
84
|
+
*
|
|
85
|
+
* Escape is not intercepted — the owner handles close-key behavior.
|
|
86
|
+
*/
|
|
87
|
+
function registerFocusTrap(container, options = {}) {
|
|
88
|
+
const initialFocus = options.initialFocus ?? 'firstFocusable';
|
|
89
|
+
const doc = container.ownerDocument;
|
|
90
|
+
const deepActive = lexical.getActiveElementDeep(doc);
|
|
91
|
+
const previouslyFocused = lexical.isHTMLElement(deepActive) ? deepActive : null;
|
|
92
|
+
const focusable = getFocusableElements(container);
|
|
93
|
+
if (initialFocus === 'container' && container.hasAttribute('tabindex')) {
|
|
94
|
+
container.focus();
|
|
95
|
+
} else if (focusable.length > 0) {
|
|
96
|
+
focusable[0].focus();
|
|
97
|
+
} else if (container.hasAttribute('tabindex')) {
|
|
98
|
+
container.focus();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Full Tab management — every Tab / Shift+Tab is handled here.
|
|
102
|
+
// Boundary-only cycling let Safari's default Tab route out to the
|
|
103
|
+
// browser chrome (URL bar) between presses, producing a visible
|
|
104
|
+
// focus flash. This trades contentEditable Tab indent for a reliable
|
|
105
|
+
// trap; the helper is Modal-only today.
|
|
106
|
+
const keydownHandler = event => {
|
|
107
|
+
if (event.key !== 'Tab') {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const currentFocusable = getFocusableElements(container);
|
|
111
|
+
if (currentFocusable.length === 0) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
const first = currentFocusable[0];
|
|
117
|
+
const last = currentFocusable[currentFocusable.length - 1];
|
|
118
|
+
const active = lexical.getActiveElementDeep(doc);
|
|
119
|
+
const activeIndex = lexical.isHTMLElement(active) && containsComposed(container, active) ? currentFocusable.indexOf(active) : -1;
|
|
120
|
+
if (event.shiftKey) {
|
|
121
|
+
(activeIndex <= 0 ? last : currentFocusable[activeIndex - 1]).focus();
|
|
122
|
+
} else {
|
|
123
|
+
(activeIndex === -1 || activeIndex === currentFocusable.length - 1 ? first : currentFocusable[activeIndex + 1]).focus();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Safety net — if focus ever escapes the container (e.g. portal
|
|
128
|
+
// ordering routes the browser's default Tab past the modal in
|
|
129
|
+
// Safari), pull it back to the first focusable inside.
|
|
130
|
+
const focusinHandler = event => {
|
|
131
|
+
const target = lexical.getComposedEventTarget(event);
|
|
132
|
+
if (!lexical.isHTMLElement(target) || containsComposed(container, target)) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (options.allowOutside != null && options.allowOutside(target)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const currentFocusable = getFocusableElements(container);
|
|
139
|
+
if (currentFocusable.length > 0) {
|
|
140
|
+
currentFocusable[0].focus();
|
|
141
|
+
} else if (container.hasAttribute('tabindex')) {
|
|
142
|
+
container.focus();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
return lexical.mergeRegister(
|
|
146
|
+
// Placed first so mergeRegister's reverse (LIFO) teardown runs it last,
|
|
147
|
+
// after the focusin listener is removed — otherwise restoring focus to an
|
|
148
|
+
// element outside the container would re-trigger the trap.
|
|
149
|
+
() => {
|
|
150
|
+
if (previouslyFocused !== null && typeof previouslyFocused.focus === 'function' && containsComposed(doc, previouslyFocused)) {
|
|
151
|
+
previouslyFocused.focus();
|
|
152
|
+
}
|
|
153
|
+
}, lexical.registerEventListener(container, 'keydown', keydownHandler), lexical.registerEventListener(doc, 'focusin', focusinHandler));
|
|
154
|
+
}
|
|
155
|
+
const DEFAULT_ROVING_SELECTOR = ':scope > button:not([disabled])';
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Implements the WAI-ARIA roving-tabindex pattern on `container`. One
|
|
159
|
+
* item carries `tabindex="0"` at a time; the rest are `-1`. Arrow keys
|
|
160
|
+
* move focus inside the group; Home / End jump to the ends. Tab leaves
|
|
161
|
+
* the group as a unit, matching the toolbar / menubar pattern.
|
|
162
|
+
*
|
|
163
|
+
* Items are queried lazily on every interaction so additions or
|
|
164
|
+
* removals during the lifetime of the group are picked up without
|
|
165
|
+
* extra wiring.
|
|
166
|
+
*/
|
|
167
|
+
function registerRovingTabIndex(container, options = {}) {
|
|
168
|
+
const orientation = options.orientation ?? 'horizontal';
|
|
169
|
+
const selector = options.itemSelector ?? DEFAULT_ROVING_SELECTOR;
|
|
170
|
+
const getItems = () => Array.from(container.querySelectorAll(selector));
|
|
171
|
+
const applyTabIndex = (items, activeIndex) => {
|
|
172
|
+
items.forEach((item, i) => {
|
|
173
|
+
item.tabIndex = i === activeIndex ? 0 : -1;
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
const init = () => {
|
|
177
|
+
const items = getItems();
|
|
178
|
+
if (items.length === 0) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const active = lexical.getActiveElementDeep(container.ownerDocument);
|
|
182
|
+
const activeIdx = items.findIndex(el => el === active);
|
|
183
|
+
applyTabIndex(items, activeIdx >= 0 ? activeIdx : 0);
|
|
184
|
+
};
|
|
185
|
+
init();
|
|
186
|
+
const handler = event => {
|
|
187
|
+
const items = getItems();
|
|
188
|
+
if (items.length === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const active = lexical.getActiveElementDeep(container.ownerDocument);
|
|
192
|
+
const currentIdx = items.findIndex(el => el === active);
|
|
193
|
+
if (currentIdx < 0) {
|
|
194
|
+
// Firefox can focus the container itself (e.g. role="toolbar") rather
|
|
195
|
+
// than a child item. Treat any arrow/Home/End as "enter the group at
|
|
196
|
+
// the first item" so the user isn't stuck.
|
|
197
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
applyTabIndex(items, 0);
|
|
200
|
+
items[0].focus();
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const horizontal = orientation === 'horizontal' || orientation === 'both';
|
|
205
|
+
const vertical = orientation === 'vertical' || orientation === 'both';
|
|
206
|
+
let nextIdx = currentIdx;
|
|
207
|
+
switch (event.key) {
|
|
208
|
+
case 'ArrowRight':
|
|
209
|
+
if (!horizontal) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
nextIdx = currentIdx + 1;
|
|
213
|
+
break;
|
|
214
|
+
case 'ArrowLeft':
|
|
215
|
+
if (!horizontal) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
nextIdx = currentIdx - 1;
|
|
219
|
+
break;
|
|
220
|
+
case 'ArrowDown':
|
|
221
|
+
if (!vertical) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
nextIdx = currentIdx + 1;
|
|
225
|
+
break;
|
|
226
|
+
case 'ArrowUp':
|
|
227
|
+
if (!vertical) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
nextIdx = currentIdx - 1;
|
|
231
|
+
break;
|
|
232
|
+
case 'Home':
|
|
233
|
+
nextIdx = 0;
|
|
234
|
+
break;
|
|
235
|
+
case 'End':
|
|
236
|
+
nextIdx = items.length - 1;
|
|
237
|
+
break;
|
|
238
|
+
default:
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
// Single wrap for every case: the only out-of-range value is -1 (left/up
|
|
243
|
+
// from the first item), and `currentIdx >= 0` keeps `nextIdx >= -1`, so one
|
|
244
|
+
// `+ length` is enough to normalize.
|
|
245
|
+
nextIdx = (nextIdx + items.length) % items.length;
|
|
246
|
+
applyTabIndex(items, nextIdx);
|
|
247
|
+
items[nextIdx].focus();
|
|
248
|
+
};
|
|
249
|
+
return lexical.mergeRegister(lexical.registerEventListener(container, 'keydown', handler), () => {
|
|
250
|
+
const items = getItems();
|
|
251
|
+
items.forEach(item => {
|
|
252
|
+
item.tabIndex = 0;
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const DEFAULT_TOOLBAR_FOCUSABLE_SELECTOR = ':scope > button:not([disabled]), :scope > [tabindex="0"]';
|
|
257
|
+
/**
|
|
258
|
+
* Implements the editor-to-toolbar focus jump recommended by the
|
|
259
|
+
* WAI-ARIA APG editor menubar pattern: Alt+F10 inside the editor moves
|
|
260
|
+
* focus to the first focusable in `toolbar`, and Escape inside the
|
|
261
|
+
* toolbar returns focus to the editor.
|
|
262
|
+
*
|
|
263
|
+
* Wires the navigation only. Selection restoration relies on the
|
|
264
|
+
* editor's own focus handling; the editor's last selection is preserved
|
|
265
|
+
* across the jump so toolbar commands act on the same range.
|
|
266
|
+
*/
|
|
267
|
+
function registerFocusManager(editor, toolbar, options = {}) {
|
|
268
|
+
const selector = options.toolbarItemSelector ?? DEFAULT_TOOLBAR_FOCUSABLE_SELECTOR;
|
|
269
|
+
const handler = event => {
|
|
270
|
+
if (event.key !== 'Escape') {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const target = lexical.getComposedEventTarget(event);
|
|
274
|
+
if (lexical.isHTMLElement(target)) {
|
|
275
|
+
const items = toolbar.querySelectorAll(selector);
|
|
276
|
+
let isRovingItem = false;
|
|
277
|
+
for (const item of items) {
|
|
278
|
+
if (item === target || containsComposed(item, target)) {
|
|
279
|
+
isRovingItem = true;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!isRovingItem) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const rootElement = editor.getRootElement();
|
|
288
|
+
if (rootElement === null) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
// Keep Escape inside the toolbar — don't let it bubble to any
|
|
293
|
+
// window-level Modal close handler.
|
|
294
|
+
event.stopPropagation();
|
|
295
|
+
// `editor.focus()` restores the prior selection so commands resume
|
|
296
|
+
// where the cursor was before the jump; the `rootElement.focus()`
|
|
297
|
+
// fallback covers test environments where `editor.focus()` does not
|
|
298
|
+
// also move DOM focus.
|
|
299
|
+
editor.focus();
|
|
300
|
+
rootElement.focus();
|
|
301
|
+
};
|
|
302
|
+
return lexical.mergeRegister(editor.registerCommand(lexical.KEY_DOWN_COMMAND, event => {
|
|
303
|
+
if (!event.altKey || event.key !== 'F10') {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const firstItem = toolbar.querySelector('[tabindex="0"]') ?? toolbar.querySelector(selector);
|
|
307
|
+
if (firstItem === null) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
firstItem.focus();
|
|
312
|
+
return true;
|
|
313
|
+
}, lexical.COMMAND_PRIORITY_LOW), lexical.registerEventListener(toolbar, 'keydown', handler));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Public output of {@link AriaLiveRegionExtension}. The `politeness` and
|
|
318
|
+
* `owner` config values are exposed as runtime-tunable signals — setting
|
|
319
|
+
* `politeness` updates the mounted region's `aria-live`, setting `owner`
|
|
320
|
+
* re-mounts the region — alongside a stable `announce` sink that is valid for
|
|
321
|
+
* the editor's lifetime (callers never see the region element or its disposal).
|
|
322
|
+
*/
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Platform-independent extension that owns a single `aria-live` region for
|
|
326
|
+
* the editor and exposes a stable {@link AriaLiveRegion} sink as its output.
|
|
327
|
+
* Other a11y extensions (`HistoryAnnounceExtension`,
|
|
328
|
+
* `EditorModeAnnounceExtension`) depend on it and announce through
|
|
329
|
+
* `output.announce`.
|
|
330
|
+
*
|
|
331
|
+
* The sink writes to a private `message` signal created in `init`. The
|
|
332
|
+
* `politeness` / `owner` config are exposed as signals (via `namedSignals`).
|
|
333
|
+
* `register` tracks the editor's root element reactively and runs three
|
|
334
|
+
* effects: one creates / disposes the region element as the root document (or
|
|
335
|
+
* `owner`) changes, one applies the current `politeness` to the mounted region,
|
|
336
|
+
* and one mirrors the current message into it. Buffering through signals
|
|
337
|
+
* decouples the stable `announce` (from `build`) from the region element —
|
|
338
|
+
* which comes and goes with the root — and creates the region in the editor's
|
|
339
|
+
* own document (e.g. an iframe-portaled editor) rather than the top-level
|
|
340
|
+
* `document`.
|
|
341
|
+
*/
|
|
342
|
+
const AriaLiveRegionExtension = /* @__PURE__ */lexical.defineExtension({
|
|
343
|
+
build(_editor, config, state) {
|
|
344
|
+
const message = state.getInitResult();
|
|
345
|
+
return {
|
|
346
|
+
...extension.namedSignals(config),
|
|
347
|
+
announce(text) {
|
|
348
|
+
// Toggle a trailing zero-width space on repeats so re-announcing the
|
|
349
|
+
// same text still registers as a textContent change downstream.
|
|
350
|
+
message.value = text === message.peek() ? text + '\u200B' : text;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
config: /* @__PURE__ */lexical.safeCast({
|
|
355
|
+
owner: null,
|
|
356
|
+
politeness: 'polite'
|
|
357
|
+
}),
|
|
358
|
+
dependencies: [extension.RootElementExtension],
|
|
359
|
+
// Private reactive message buffer shared by `build` (writer) and `register`
|
|
360
|
+
// (renderer); see the extension doc comment.
|
|
361
|
+
init: () => extension.signal(''),
|
|
362
|
+
name: '@lexical/a11y/AriaLiveRegion',
|
|
363
|
+
register(_editor, _config, state) {
|
|
364
|
+
const message = state.getInitResult();
|
|
365
|
+
const {
|
|
366
|
+
owner,
|
|
367
|
+
politeness
|
|
368
|
+
} = state.getOutput();
|
|
369
|
+
const rootElement = state.getDependency(extension.RootElementExtension).output;
|
|
370
|
+
const region = extension.signal(null);
|
|
371
|
+
return lexical.mergeRegister(
|
|
372
|
+
// Create / dispose the region as the root document (or `owner`) changes.
|
|
373
|
+
// Subscribe to the root only when no explicit `owner` is configured, so a
|
|
374
|
+
// fixed-owner region isn't torn down and rebuilt on unrelated root churn.
|
|
375
|
+
extension.effect(() => {
|
|
376
|
+
const ownerEl = owner.value;
|
|
377
|
+
const root = ownerEl ? null : rootElement.value;
|
|
378
|
+
const host = ownerEl ?? (root ? root.ownerDocument.body : null);
|
|
379
|
+
if (!host) {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
const el = createLiveRegion(host);
|
|
383
|
+
region.value = el;
|
|
384
|
+
return () => {
|
|
385
|
+
el.remove();
|
|
386
|
+
region.value = null;
|
|
387
|
+
};
|
|
388
|
+
}),
|
|
389
|
+
// Apply the (runtime-tunable) politeness to whatever region is mounted.
|
|
390
|
+
// This tracks `region` so it re-applies the attribute to a re-created
|
|
391
|
+
// region (an attribute, not an announcement).
|
|
392
|
+
extension.effect(() => {
|
|
393
|
+
const el = region.value;
|
|
394
|
+
if (el) {
|
|
395
|
+
el.setAttribute('aria-live', politeness.value);
|
|
396
|
+
}
|
|
397
|
+
}),
|
|
398
|
+
// Mirror the current message into whatever region is mounted. Always read
|
|
399
|
+
// `message.value` (to stay subscribed) but read the region with peek() so
|
|
400
|
+
// this re-runs only when the message changes, not when the region is
|
|
401
|
+
// (re)created: replaying the buffered message into a freshly mounted
|
|
402
|
+
// region would make the screen reader re-announce it on every editor
|
|
403
|
+
// remount with no user action.
|
|
404
|
+
extension.effect(() => {
|
|
405
|
+
const text = message.value;
|
|
406
|
+
const el = region.peek();
|
|
407
|
+
if (el) {
|
|
408
|
+
el.textContent = text;
|
|
409
|
+
}
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
/**
|
|
414
|
+
* Platform-independent extension that announces undo / redo through the
|
|
415
|
+
* `AriaLiveRegionExtension`'s shared sink.
|
|
416
|
+
*/
|
|
417
|
+
const HistoryAnnounceExtension = /* @__PURE__ */lexical.defineExtension({
|
|
418
|
+
build: (_editor, config) => extension.namedSignals(config),
|
|
419
|
+
config: /* @__PURE__ */lexical.safeCast({
|
|
420
|
+
disabled: false,
|
|
421
|
+
redone: 'Redone',
|
|
422
|
+
undone: 'Undone'
|
|
423
|
+
}),
|
|
424
|
+
dependencies: [AriaLiveRegionExtension],
|
|
425
|
+
name: '@lexical/a11y/HistoryAnnounce',
|
|
426
|
+
register(editor, _config, state) {
|
|
427
|
+
const {
|
|
428
|
+
disabled,
|
|
429
|
+
redone,
|
|
430
|
+
undone
|
|
431
|
+
} = state.getOutput();
|
|
432
|
+
const {
|
|
433
|
+
announce
|
|
434
|
+
} = state.getDependency(AriaLiveRegionExtension).output;
|
|
435
|
+
// Gate registration on `disabled` from an effect so a disabled announcer
|
|
436
|
+
// adds no command overhead at all. Peek the message signals at dispatch
|
|
437
|
+
// time so changing them doesn't re-register. Both return false to keep the
|
|
438
|
+
// history command chain intact.
|
|
439
|
+
return extension.effect(() => disabled.value ? undefined : lexical.mergeRegister(editor.registerCommand(lexical.UNDO_COMMAND, () => {
|
|
440
|
+
announce(undone.peek());
|
|
441
|
+
return false;
|
|
442
|
+
}, lexical.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical.REDO_COMMAND, () => {
|
|
443
|
+
announce(redone.peek());
|
|
444
|
+
return false;
|
|
445
|
+
}, lexical.COMMAND_PRIORITY_LOW)));
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
/**
|
|
449
|
+
* Platform-independent extension that announces
|
|
450
|
+
* `editor.setEditable(true|false)` transitions through the
|
|
451
|
+
* `AriaLiveRegionExtension`'s shared sink.
|
|
452
|
+
*/
|
|
453
|
+
const EditorModeAnnounceExtension = /* @__PURE__ */lexical.defineExtension({
|
|
454
|
+
build: (_editor, config) => extension.namedSignals(config),
|
|
455
|
+
config: /* @__PURE__ */lexical.safeCast({
|
|
456
|
+
disabled: false,
|
|
457
|
+
editable: 'Editor is editable',
|
|
458
|
+
readOnly: 'Editor is read-only'
|
|
459
|
+
}),
|
|
460
|
+
dependencies: [AriaLiveRegionExtension],
|
|
461
|
+
name: '@lexical/a11y/EditorModeAnnounce',
|
|
462
|
+
register(editor, _config, state) {
|
|
463
|
+
const {
|
|
464
|
+
disabled,
|
|
465
|
+
editable,
|
|
466
|
+
readOnly
|
|
467
|
+
} = state.getOutput();
|
|
468
|
+
const {
|
|
469
|
+
announce
|
|
470
|
+
} = state.getDependency(AriaLiveRegionExtension).output;
|
|
471
|
+
// Gate registration on `disabled` from an effect so a disabled announcer
|
|
472
|
+
// adds no listener overhead. Peek the message signals at dispatch time so
|
|
473
|
+
// changing them doesn't re-register.
|
|
474
|
+
return extension.effect(() => disabled.value ? undefined : editor.registerEditableListener(isEditable => {
|
|
475
|
+
announce(isEditable ? editable.peek() : readOnly.peek());
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The public output of the focus-trap, roving-tabindex and focus-manager
|
|
482
|
+
* extensions: register a container (reference counted) and get back an
|
|
483
|
+
* idempotent disposer. Callers register through this method instead of a
|
|
484
|
+
* mutable map and never see the container bookkeeping. It is the core
|
|
485
|
+
* {@link RefCountedRegistry} keyed by `HTMLElement`.
|
|
486
|
+
*
|
|
487
|
+
* Each extension creates the registry in `build` (where the editor is
|
|
488
|
+
* available for the per-container activation) and disposes it on `register`
|
|
489
|
+
* teardown via {@link RefCountedRegistry.dispose}.
|
|
490
|
+
*/
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Platform-independent extension that traps Tab / Shift+Tab focus inside one
|
|
494
|
+
* or more containers. Register a container through the extension output
|
|
495
|
+
* ({@link ContainerRegistry.register}); the React adapter is
|
|
496
|
+
* `useLexicalFocusTrapRef` from `@lexical/react`.
|
|
497
|
+
*/
|
|
498
|
+
const FocusTrapExtension = /* @__PURE__ */lexical.defineExtension({
|
|
499
|
+
build: () => lexical.createRefCountedRegistry(registerFocusTrap),
|
|
500
|
+
name: '@lexical/a11y/FocusTrap',
|
|
501
|
+
register: (_editor, _config, state) => () => state.getOutput().dispose()
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Platform-independent extension that wires the WAI-ARIA roving-tabindex
|
|
506
|
+
* pattern on one or more containers. Register a container through the
|
|
507
|
+
* extension output ({@link ContainerRegistry.register}); the React adapter is
|
|
508
|
+
* `useLexicalRovingTabIndexRef` from `@lexical/react`.
|
|
509
|
+
*/
|
|
510
|
+
const RovingTabIndexExtension = /* @__PURE__ */lexical.defineExtension({
|
|
511
|
+
build: () => lexical.createRefCountedRegistry(registerRovingTabIndex),
|
|
512
|
+
name: '@lexical/a11y/RovingTabIndex',
|
|
513
|
+
register: (_editor, _config, state) => () => state.getOutput().dispose()
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Platform-independent extension that wires the editor-to-toolbar focus jump
|
|
518
|
+
* (Alt+F10 / Escape return) on one or more toolbars. Register a toolbar
|
|
519
|
+
* through the extension output ({@link ContainerRegistry.register}); the React
|
|
520
|
+
* adapter is `useLexicalFocusManagerRef` from `@lexical/react`.
|
|
521
|
+
*/
|
|
522
|
+
const FocusManagerExtension = /* @__PURE__ */lexical.defineExtension({
|
|
523
|
+
build: editor => lexical.createRefCountedRegistry((toolbar, options) => registerFocusManager(editor, toolbar, options)),
|
|
524
|
+
name: '@lexical/a11y/FocusManager',
|
|
525
|
+
register: (_editor, _config, state) => () => state.getOutput().dispose()
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
exports.AriaLiveRegionExtension = AriaLiveRegionExtension;
|
|
529
|
+
exports.EditorModeAnnounceExtension = EditorModeAnnounceExtension;
|
|
530
|
+
exports.FocusManagerExtension = FocusManagerExtension;
|
|
531
|
+
exports.FocusTrapExtension = FocusTrapExtension;
|
|
532
|
+
exports.HistoryAnnounceExtension = HistoryAnnounceExtension;
|
|
533
|
+
exports.RovingTabIndexExtension = RovingTabIndexExtension;
|