@richscripts/richtexteditor 2.5.2 → 2.5.4

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.
@@ -1,342 +1,370 @@
1
- if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
-
3
- // 2026-08-02 Keyboard and state accessibility for the editor CHROME.
4
- //
5
- // a11yenhance.js names the editing region and dialoga11y.js names the dialogs.
6
- // This closes what an audit of the running editor found still open — four
7
- // findings, three of them WCAG Level A:
8
- //
9
- // 1. KEYBOARD TRAP (2.1.2, Level A). Tab inside the editing area is
10
- // preventDefault-ed and inserts spaces, so a keyboard-only or screen
11
- // reader user who enters the editor can never leave it. Reloading the page
12
- // is the only way out. This is the most serious kind of accessibility
13
- // defect: it does not degrade the experience, it ends it.
14
- //
15
- // 2. TOGGLE STATE NOT EXPOSED (4.1.2, Level A). Bold, italic, underline and
16
- // the alignment buttons carry their state in a CSS class
17
- // (rte-command-active / rte-command-deactive) and nowhere else. Sighted
18
- // users see a highlighted button; assistive technology is told nothing.
19
- // Measured: 0 of 6 toggle buttons exposed aria-pressed.
20
- //
21
- // 3. POPUP STATE NOT EXPOSED (4.1.2, Level A). Buttons carry
22
- // aria-haspopup but never aria-expanded, so there is no way to know
23
- // whether a menu is open. Measured: 0 of 7.
24
- //
25
- // 4. EVERY TOOLBAR BUTTON IS A TAB STOP (2.4.3, and plain usability).
26
- // Measured 53. The ARIA Authoring Practices toolbar pattern is a single
27
- // tab stop per toolbar with arrow keys moving between buttons. 53 presses
28
- // of Tab to reach the text you came to write is not operable in any
29
- // meaningful sense.
30
- //
31
- // All four are fixed here rather than in the core, so they ship without a
32
- // re-obfuscation cycle.
33
- //
34
- // Config:
35
- // config.keyboardA11y = false // opt out entirely
36
- // config.a11yEscapeHint = "..." // wording appended to the editing area's name
37
- // config.a11yRovingToolbar = false // keep every button as a tab stop
38
- RTE_DefaultConfig.plugin_keyboarda11y = RTE_Plugin_KeyboardA11y;
39
- if (typeof RTE_DefaultConfig.keyboardA11y === "undefined") RTE_DefaultConfig.keyboardA11y = true;
40
-
41
- function RTE_Plugin_KeyboardA11y() {
42
- var obj = this;
43
- var config, editor;
44
- var observers = [];
45
- var popupOwner = null; // last activated [aria-haspopup]
46
- var openPanels = []; // [{ panel, owner }]
47
-
48
- obj.PluginName = "KeyboardA11y";
49
-
50
- obj.InitConfig = function (argconfig) { config = argconfig; };
51
-
52
- obj.InitEditor = function (argeditor) {
53
- editor = argeditor;
54
- if (config.keyboardA11y === false) return;
55
-
56
- editor.focusToolbar = function () { return focusFirstToolbarButton(); };
57
-
58
- setup();
59
- try { editor.attachEvent("ready", setup); } catch (e) {}
60
- // The toolbar is built asynchronously; a deferred pass catches the case
61
- // where InitEditor runs before it exists.
62
- setTimeout(setup, 0);
63
- setTimeout(setup, 400);
64
- };
65
-
66
- function shell() {
67
- try {
68
- var ed = editor.getEditable();
69
- if (!ed) return null;
70
- var win = ed.ownerDocument.defaultView;
71
- var node = (win && win.frameElement) ? win.frameElement : ed;
72
- while (node && node.classList && !node.classList.contains("richtexteditor")) node = node.parentNode;
73
- return (node && node.classList) ? node : null;
74
- } catch (e) { return null; }
75
- }
76
-
77
- function setup() {
78
- var root = shell();
79
- if (!root) return;
80
- bindEscapeHatch();
81
- applyRovingTabindex(root);
82
- syncToggleStates(root);
83
- trackPopups(root);
84
- watch(root);
85
- }
86
-
87
- // ---------------------------------------------------- 1. keyboard trap
88
- //
89
- // Tab keeps its editing meaning (indent, next table cell) because that is
90
- // what writers expect and what every other editor does. The escape is a
91
- // separate, documented key: Escape leaves the editing area and puts focus
92
- // on the toolbar, from which Tab continues through the page normally.
93
- //
94
- // Escape is only intercepted when nothing is open — a dialog or dropdown
95
- // must still get its own Escape first, or closing a colour picker would
96
- // throw the user out of the editor.
97
- function bindEscapeHatch() {
98
- var ed;
99
- try { ed = editor.getEditable(); } catch (e) { return; }
100
- if (!ed || ed.__rteEscapeHatch) return;
101
- ed.__rteEscapeHatch = true;
102
-
103
- ed.addEventListener("keydown", function (e) {
104
- if (e.key !== "Escape" && e.keyCode !== 27) return;
105
- if (anythingOpen()) return; // let the panel close itself
106
- e.preventDefault();
107
- e.stopPropagation();
108
- if (!focusFirstToolbarButton()) focusAfterEditor();
109
- }, false);
110
-
111
- announceEscape(ed);
112
- }
113
-
114
- function anythingOpen() {
115
- try {
116
- var panels = document.querySelectorAll("rte-dropdown-panel, rte-dialog-float, rte-floatpanel");
117
- for (var i = 0; i < panels.length; i++) {
118
- var s = getComputedStyle(panels[i]);
119
- if (s.display !== "none" && s.visibility !== "hidden") return true;
120
- }
121
- } catch (e) {}
122
- return false;
123
- }
124
-
125
- // The way out has to be discoverable, or it may as well not exist. The
126
- // editing region's accessible name is the one thing a screen reader always
127
- // announces on entry, so the hint goes there rather than into the content.
128
- function announceEscape(ed) {
129
- var hint = config.a11yEscapeHint ||
130
- "Press Escape to leave the editing area.";
131
- var label = ed.getAttribute("aria-label") || "";
132
- if (label.indexOf(hint) >= 0) return;
133
- ed.setAttribute("aria-label", (label ? label.replace(/\s*$/, " ") : "") + hint);
134
- }
135
-
136
- function focusAfterEditor() {
137
- var root = shell();
138
- if (!root) return false;
139
- var all = [].slice.call(document.querySelectorAll(
140
- 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])'
141
- )).filter(function (el) {
142
- return !root.contains(el) && el.offsetParent !== null;
143
- });
144
- // The first focusable that follows the editor in document order.
145
- for (var i = 0; i < all.length; i++) {
146
- if (root.compareDocumentPosition(all[i]) & Node.DOCUMENT_POSITION_FOLLOWING) {
147
- all[i].focus();
148
- return true;
149
- }
150
- }
151
- return false;
152
- }
153
-
154
- // ------------------------------------------------- 4. roving tabindex
155
- function toolbarsIn(root) {
156
- return [].slice.call(root.querySelectorAll('[role="toolbar"]'));
157
- }
158
- // Toolbars nest — an overflow or ribbon toolbar sits inside the main one.
159
- // A plain descendant query therefore makes the outer toolbar claim the
160
- // inner one's buttons, and the two passes fight: the first run measured two
161
- // tab stops in one toolbar and NONE in the other two, which left those
162
- // buttons unreachable by keyboard altogether. Each button belongs to its
163
- // NEAREST toolbar and to no other.
164
- function buttonsIn(bar) {
165
- return [].slice.call(bar.querySelectorAll('[role="button"]')).filter(function (b) {
166
- if (b.getAttribute("aria-disabled") === "true") return false;
167
- if (b.offsetParent === null) return false; // hidden: not reachable anyway
168
- return (b.closest && b.closest('[role="toolbar"]')) === bar;
169
- });
170
- }
171
-
172
- function applyRovingTabindex(root) {
173
- if (config.a11yRovingToolbar === false) return;
174
- var bars = toolbarsIn(root);
175
- for (var i = 0; i < bars.length; i++) {
176
- (function (bar) {
177
- var btns = buttonsIn(bar);
178
- if (!btns.length) return;
179
- // Exactly one tab stop per toolbar, which is the ARIA pattern.
180
- var current = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
181
- for (var j = 0; j < btns.length; j++) btns[j].setAttribute("tabindex", btns[j] === current ? "0" : "-1");
182
-
183
- if (bar.__rteRoving) return;
184
- bar.__rteRoving = true;
185
- bar.addEventListener("keydown", function (e) {
186
- var list = buttonsIn(bar);
187
- var at = list.indexOf(document.activeElement);
188
- if (at < 0) return;
189
- var rtl = (bar.closest && bar.closest('[dir="rtl"]')) ? true : false;
190
- var next = null;
191
- if (e.key === "ArrowRight") next = list[(at + (rtl ? -1 : 1) + list.length) % list.length];
192
- else if (e.key === "ArrowLeft") next = list[(at + (rtl ? 1 : -1) + list.length) % list.length];
193
- else if (e.key === "Home") next = list[0];
194
- else if (e.key === "End") next = list[list.length - 1];
195
- else return;
196
- e.preventDefault();
197
- for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === next ? "0" : "-1");
198
- next.focus();
199
- }, false);
200
- // Clicking a button makes it the new tab stop, so returning by
201
- // Tab lands where the user last was.
202
- bar.addEventListener("focusin", function (e) {
203
- var list = buttonsIn(bar);
204
- if (list.indexOf(e.target) < 0) return;
205
- for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === e.target ? "0" : "-1");
206
- }, false);
207
- })(bars[i]);
208
- }
209
- }
210
-
211
- function focusFirstToolbarButton() {
212
- var root = shell();
213
- if (!root) return false;
214
- var bars = toolbarsIn(root);
215
- for (var i = 0; i < bars.length; i++) {
216
- var btns = buttonsIn(bars[i]);
217
- if (!btns.length) continue;
218
- var target = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
219
- target.focus();
220
- return true;
221
- }
222
- return false;
223
- }
224
-
225
- // ------------------------------------------------- 2. aria-pressed
226
- //
227
- // The editor already tracks active state — it just keeps it in a class.
228
- // Mirroring rather than recomputing means the announced state can never
229
- // disagree with the highlighted button.
230
- // Command names here must match the TOOLBAR command exactly — the regex is
231
- // anchored, so a near-miss is a silent no-op that looks like coverage.
232
- // "blockquote" is one: the command is `insertblockquote`, so the entry
233
- // written specifically for it never matched and that button ships with no
234
- // aria-pressed. Found by checking every genuine toggle on the full toolbar
235
- // rather than the three the published verify page samples.
236
- //
237
- // This mirror only copies the editor's own active state, so a command may
238
- // only be listed here once core actually tracks it. Emitting the attribute
239
- // for an untracked command yields a permanent aria-pressed="false" —
240
- // confidently announcing "not pressed" while the formatting IS applied,
241
- // which makes announced state DISAGREE with the highlight. That is the exact
242
- // failure this mirror exists to prevent, so silence beats a wrong answer.
243
- // - `toggleborder` was already tracked (core checks the editable's
244
- // rte-toggleborder class); it reads inactive simply when borders are off.
245
- // - `insertblockquote` was NOT tracked and now is, via a core case added
246
- // alongside `indent`, which resolves the same way.
247
- // Both verified on the running editor rather than inferred.
248
- //
249
- // `inlinecode`, `pagination`, `typewriter` and `focusmode` match nothing in
250
- // this build, and now the reason is known rather than assumed: pagination.js
251
- // and typewriter.js are API-only (togglePageView / toggleTypewriterMode …)
252
- // and register no toolbar command, and insertcode.js registers `insertcode`,
253
- // which inserts a block rather than toggling the selection. They are kept —
254
- // harmless, and correct the moment a toolbar button is added — but they
255
- // cover nothing today, so do not read this list as evidence of coverage.
256
- var TOGGLE_CMD = /^(bold|italic|underline|strikethrough|subscript|superscript|justifyleft|justifycenter|justifyright|justifyfull|insertorderedlist|insertunorderedlist|outdent|indent|insertblockquote|toggleborder|inlinecode|trackchanges|typewriter|focusmode|pagination|formattingmarks|linenumbers|permanentpen|rtlui)$/;
257
-
258
- function syncToggleStates(root) {
259
- var btns = [].slice.call(root.querySelectorAll('[role="button"][rte-cmd-name]'));
260
- for (var i = 0; i < btns.length; i++) {
261
- var cmd = (btns[i].getAttribute("rte-cmd-name") || "").toLowerCase();
262
- if (!TOGGLE_CMD.test(cmd)) continue;
263
- var active = btns[i].classList.contains("rte-command-active");
264
- var pressed = active ? "true" : "false";
265
- if (btns[i].getAttribute("aria-pressed") !== pressed) btns[i].setAttribute("aria-pressed", pressed);
266
- }
267
- }
268
-
269
- // ------------------------------------------------- 3. aria-expanded
270
- //
271
- // Opening a menu inserts an <rte-dropdown-panel> elsewhere in the document
272
- // and leaves the button untouched, so the button and its panel have to be
273
- // correlated: remember which popup button was activated, then pair it with
274
- // the panel that appears.
275
- function trackPopups(root) {
276
- var pops = [].slice.call(root.querySelectorAll("[aria-haspopup]"));
277
- for (var i = 0; i < pops.length; i++) {
278
- if (!pops[i].hasAttribute("aria-expanded")) pops[i].setAttribute("aria-expanded", "false");
279
- if (pops[i].__rtePopupBound) continue;
280
- pops[i].__rtePopupBound = true;
281
- (function (el) {
282
- function remember() { popupOwner = el; }
283
- el.addEventListener("mousedown", remember, true);
284
- el.addEventListener("keydown", function (e) {
285
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") remember();
286
- }, true);
287
- })(pops[i]);
288
- }
289
-
290
- if (trackPopups.bound) return;
291
- trackPopups.bound = true;
292
- var mo = new MutationObserver(function (records) {
293
- for (var r = 0; r < records.length; r++) {
294
- var rec = records[r];
295
- for (var a = 0; a < rec.addedNodes.length; a++) {
296
- var n = rec.addedNodes[a];
297
- if (n.nodeType !== 1 || !isPanel(n)) continue;
298
- if (popupOwner) {
299
- popupOwner.setAttribute("aria-expanded", "true");
300
- openPanels.push({ panel: n, owner: popupOwner });
301
- popupOwner = null;
302
- }
303
- }
304
- for (var d = 0; d < rec.removedNodes.length; d++) {
305
- var m = rec.removedNodes[d];
306
- if (m.nodeType !== 1 || !isPanel(m)) continue;
307
- for (var k = openPanels.length - 1; k >= 0; k--) {
308
- if (openPanels[k].panel === m) {
309
- openPanels[k].owner.setAttribute("aria-expanded", "false");
310
- openPanels.splice(k, 1);
311
- }
312
- }
313
- }
314
- }
315
- });
316
- mo.observe(document.body, { childList: true, subtree: true });
317
- observers.push(mo);
318
- }
319
- function isPanel(el) {
320
- var t = (el.tagName || "").toLowerCase();
321
- return t === "rte-dropdown-panel" || t === "rte-floatpanel" || t === "rte-dialog-float";
322
- }
323
-
324
- // Toolbar buttons are rebuilt and re-classed as the selection moves, so the
325
- // mirrored state has to follow rather than be set once.
326
- function watch(root) {
327
- if (root.__rteA11yWatch) return;
328
- root.__rteA11yWatch = true;
329
- var pending = null;
330
- var mo = new MutationObserver(function () {
331
- if (pending) return;
332
- pending = setTimeout(function () {
333
- pending = null;
334
- syncToggleStates(root);
335
- applyRovingTabindex(root);
336
- trackPopups(root);
337
- }, 60);
338
- });
339
- mo.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-disabled"] });
340
- observers.push(mo);
341
- }
342
- }
1
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
+
3
+ // 2026-08-02 Keyboard and state accessibility for the editor CHROME.
4
+ //
5
+ // a11yenhance.js names the editing region and dialoga11y.js names the dialogs.
6
+ // This closes what an audit of the running editor found still open — four
7
+ // findings, three of them WCAG Level A:
8
+ //
9
+ // 1. KEYBOARD TRAP (2.1.2, Level A). Tab inside the editing area is
10
+ // preventDefault-ed and inserts spaces, so a keyboard-only or screen
11
+ // reader user who enters the editor can never leave it. Reloading the page
12
+ // is the only way out. This is the most serious kind of accessibility
13
+ // defect: it does not degrade the experience, it ends it.
14
+ //
15
+ // 2. TOGGLE STATE NOT EXPOSED (4.1.2, Level A). Bold, italic, underline and
16
+ // the alignment buttons carry their state in a CSS class
17
+ // (rte-command-active / rte-command-deactive) and nowhere else. Sighted
18
+ // users see a highlighted button; assistive technology is told nothing.
19
+ // Measured: 0 of 6 toggle buttons exposed aria-pressed.
20
+ //
21
+ // 3. POPUP STATE NOT EXPOSED (4.1.2, Level A). Buttons carry
22
+ // aria-haspopup but never aria-expanded, so there is no way to know
23
+ // whether a menu is open. Measured: 0 of 7.
24
+ //
25
+ // 4. EVERY TOOLBAR BUTTON IS A TAB STOP (2.4.3, and plain usability).
26
+ // Measured 53. The ARIA Authoring Practices toolbar pattern is a single
27
+ // tab stop per toolbar with arrow keys moving between buttons. 53 presses
28
+ // of Tab to reach the text you came to write is not operable in any
29
+ // meaningful sense.
30
+ //
31
+ // All four are fixed here rather than in the core, so they ship without a
32
+ // re-obfuscation cycle.
33
+ //
34
+ // Config:
35
+ // config.keyboardA11y = false // opt out entirely
36
+ // config.a11yEscapeHint = "..." // wording appended to the editing area's name
37
+ // config.a11yEditorLabel = "..." // accessible NAME of the editing area (default "Rich text editor")
38
+ // config.a11yRovingToolbar = false // keep every button as a tab stop
39
+ RTE_DefaultConfig.plugin_keyboarda11y = RTE_Plugin_KeyboardA11y;
40
+ if (typeof RTE_DefaultConfig.keyboardA11y === "undefined") RTE_DefaultConfig.keyboardA11y = true;
41
+
42
+ function RTE_Plugin_KeyboardA11y() {
43
+ var obj = this;
44
+ var config, editor;
45
+ var observers = [];
46
+ var popupOwner = null; // last activated [aria-haspopup]
47
+ var openPanels = []; // [{ panel, owner }]
48
+
49
+ obj.PluginName = "KeyboardA11y";
50
+
51
+ obj.InitConfig = function (argconfig) { config = argconfig; };
52
+
53
+ obj.InitEditor = function (argeditor) {
54
+ editor = argeditor;
55
+ if (config.keyboardA11y === false) return;
56
+
57
+ editor.focusToolbar = function () { return focusFirstToolbarButton(); };
58
+
59
+ setup();
60
+ try { editor.attachEvent("ready", setup); } catch (e) {}
61
+ // The toolbar is built asynchronously; a deferred pass catches the case
62
+ // where InitEditor runs before it exists.
63
+ setTimeout(setup, 0);
64
+ setTimeout(setup, 400);
65
+ };
66
+
67
+ function shell() {
68
+ try {
69
+ var ed = editor.getEditable();
70
+ if (!ed) return null;
71
+ var win = ed.ownerDocument.defaultView;
72
+ var node = (win && win.frameElement) ? win.frameElement : ed;
73
+ while (node && node.classList && !node.classList.contains("richtexteditor")) node = node.parentNode;
74
+ return (node && node.classList) ? node : null;
75
+ } catch (e) { return null; }
76
+ }
77
+
78
+ function setup() {
79
+ var root = shell();
80
+ if (!root) return;
81
+ bindEscapeHatch();
82
+ applyRovingTabindex(root);
83
+ syncToggleStates(root);
84
+ trackPopups(root);
85
+ watch(root);
86
+ }
87
+
88
+ // ---------------------------------------------------- 1. keyboard trap
89
+ //
90
+ // Tab keeps its editing meaning (indent, next table cell) because that is
91
+ // what writers expect and what every other editor does. The escape is a
92
+ // separate, documented key: Escape leaves the editing area and puts focus
93
+ // on the toolbar, from which Tab continues through the page normally.
94
+ //
95
+ // Escape is only intercepted when nothing is open — a dialog or dropdown
96
+ // must still get its own Escape first, or closing a colour picker would
97
+ // throw the user out of the editor.
98
+ // Bound on the DOCUMENT rather than on the body element. Escape bubbles, so
99
+ // one listener at document level covers the editing area no matter how the
100
+ // surface is re-created, and it does not depend on a marker flag pinned to a
101
+ // particular element surviving. Defensive only: no shipped operation is known
102
+ // to replace the editing body — setHTML, htmlview, fullscreen, readingmode,
103
+ // preview and toggleborder all preserve its identity (measured 2026-08-27).
104
+ // Keeping the binding here costs nothing and removes an assumption, but it
105
+ // fixes no known live defect; do not describe it as a bug fix.
106
+ function bindEscapeHatch() {
107
+ var ed, doc;
108
+ try { ed = editor.getEditable(); doc = ed && ed.ownerDocument; } catch (e) { return; }
109
+ if (!ed || !doc) return;
110
+
111
+ if (!doc.__rteEscapeHatch) {
112
+ doc.__rteEscapeHatch = true;
113
+ doc.addEventListener("keydown", function (e) {
114
+ if (e.key !== "Escape" && e.keyCode !== 27) return;
115
+ if (anythingOpen()) return; // let the panel close itself
116
+ e.preventDefault();
117
+ e.stopPropagation();
118
+ if (!focusFirstToolbarButton()) focusAfterEditor();
119
+ }, false);
120
+
121
+ // Deliberately not re-asserting the body's aria-label here. An
122
+ // earlier version did, on the theory that the editing body could be
123
+ // replaced underneath us; that mechanism was retracted 2026-08-27
124
+ // after it turned out to be an instrumentation artefact, and
125
+ // editor.getEditable() is verified to return the live, connected
126
+ // body on every shipped path. announceEscape() at the end of this
127
+ // function is sufficient.
128
+ }
129
+
130
+ announceEscape(ed);
131
+ }
132
+
133
+ function anythingOpen() {
134
+ try {
135
+ var panels = document.querySelectorAll("rte-dropdown-panel, rte-dialog-float, rte-floatpanel");
136
+ for (var i = 0; i < panels.length; i++) {
137
+ var s = getComputedStyle(panels[i]);
138
+ if (s.display !== "none" && s.visibility !== "hidden") return true;
139
+ }
140
+ } catch (e) {}
141
+ return false;
142
+ }
143
+
144
+ // The way out has to be discoverable, or it may as well not exist. The
145
+ // editing region's accessible name is the one thing a screen reader always
146
+ // announces on entry, so the hint goes there rather than into the content.
147
+ //
148
+ // But an instruction is not a NAME. When the host page had not labelled the
149
+ // editing area, appending the hint to an empty label left the region's
150
+ // accessible name as nothing but "Press Escape to leave the editing area." —
151
+ // a screen reader then told the user how to LEAVE a field without ever
152
+ // saying what it was, and two editors on one page were indistinguishable.
153
+ // So: a host-supplied label still wins as the name; otherwise we supply a
154
+ // real one and the hint follows it.
155
+ function announceEscape(ed) {
156
+ var hint = config.a11yEscapeHint ||
157
+ "Press Escape to leave the editing area.";
158
+ var label = ed.getAttribute("aria-label") || "";
159
+ if (label.indexOf(hint) >= 0) return;
160
+ var name = label || config.a11yEditorLabel || "Rich text editor";
161
+ ed.setAttribute("aria-label", name.replace(/\s*$/, "") + " " + hint);
162
+ }
163
+
164
+ function focusAfterEditor() {
165
+ var root = shell();
166
+ if (!root) return false;
167
+ var all = [].slice.call(document.querySelectorAll(
168
+ 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])'
169
+ )).filter(function (el) {
170
+ return !root.contains(el) && el.offsetParent !== null;
171
+ });
172
+ // The first focusable that follows the editor in document order.
173
+ for (var i = 0; i < all.length; i++) {
174
+ if (root.compareDocumentPosition(all[i]) & Node.DOCUMENT_POSITION_FOLLOWING) {
175
+ all[i].focus();
176
+ return true;
177
+ }
178
+ }
179
+ return false;
180
+ }
181
+
182
+ // ------------------------------------------------- 4. roving tabindex
183
+ function toolbarsIn(root) {
184
+ return [].slice.call(root.querySelectorAll('[role="toolbar"]'));
185
+ }
186
+ // Toolbars nest — an overflow or ribbon toolbar sits inside the main one.
187
+ // A plain descendant query therefore makes the outer toolbar claim the
188
+ // inner one's buttons, and the two passes fight: the first run measured two
189
+ // tab stops in one toolbar and NONE in the other two, which left those
190
+ // buttons unreachable by keyboard altogether. Each button belongs to its
191
+ // NEAREST toolbar and to no other.
192
+ function buttonsIn(bar) {
193
+ return [].slice.call(bar.querySelectorAll('[role="button"]')).filter(function (b) {
194
+ if (b.getAttribute("aria-disabled") === "true") return false;
195
+ if (b.offsetParent === null) return false; // hidden: not reachable anyway
196
+ return (b.closest && b.closest('[role="toolbar"]')) === bar;
197
+ });
198
+ }
199
+
200
+ function applyRovingTabindex(root) {
201
+ if (config.a11yRovingToolbar === false) return;
202
+ var bars = toolbarsIn(root);
203
+ for (var i = 0; i < bars.length; i++) {
204
+ (function (bar) {
205
+ var btns = buttonsIn(bar);
206
+ if (!btns.length) return;
207
+ // Exactly one tab stop per toolbar, which is the ARIA pattern.
208
+ var current = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
209
+ for (var j = 0; j < btns.length; j++) btns[j].setAttribute("tabindex", btns[j] === current ? "0" : "-1");
210
+
211
+ if (bar.__rteRoving) return;
212
+ bar.__rteRoving = true;
213
+ bar.addEventListener("keydown", function (e) {
214
+ var list = buttonsIn(bar);
215
+ var at = list.indexOf(document.activeElement);
216
+ if (at < 0) return;
217
+ var rtl = (bar.closest && bar.closest('[dir="rtl"]')) ? true : false;
218
+ var next = null;
219
+ if (e.key === "ArrowRight") next = list[(at + (rtl ? -1 : 1) + list.length) % list.length];
220
+ else if (e.key === "ArrowLeft") next = list[(at + (rtl ? 1 : -1) + list.length) % list.length];
221
+ else if (e.key === "Home") next = list[0];
222
+ else if (e.key === "End") next = list[list.length - 1];
223
+ else return;
224
+ e.preventDefault();
225
+ for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === next ? "0" : "-1");
226
+ next.focus();
227
+ }, false);
228
+ // Clicking a button makes it the new tab stop, so returning by
229
+ // Tab lands where the user last was.
230
+ bar.addEventListener("focusin", function (e) {
231
+ var list = buttonsIn(bar);
232
+ if (list.indexOf(e.target) < 0) return;
233
+ for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === e.target ? "0" : "-1");
234
+ }, false);
235
+ })(bars[i]);
236
+ }
237
+ }
238
+
239
+ function focusFirstToolbarButton() {
240
+ var root = shell();
241
+ if (!root) return false;
242
+ var bars = toolbarsIn(root);
243
+ for (var i = 0; i < bars.length; i++) {
244
+ var btns = buttonsIn(bars[i]);
245
+ if (!btns.length) continue;
246
+ var target = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
247
+ target.focus();
248
+ return true;
249
+ }
250
+ return false;
251
+ }
252
+
253
+ // ------------------------------------------------- 2. aria-pressed
254
+ //
255
+ // The editor already tracks active state — it just keeps it in a class.
256
+ // Mirroring rather than recomputing means the announced state can never
257
+ // disagree with the highlighted button.
258
+ // Command names here must match the TOOLBAR command exactly — the regex is
259
+ // anchored, so a near-miss is a silent no-op that looks like coverage.
260
+ // "blockquote" is one: the command is `insertblockquote`, so the entry
261
+ // written specifically for it never matched and that button ships with no
262
+ // aria-pressed. Found by checking every genuine toggle on the full toolbar
263
+ // rather than the three the published verify page samples.
264
+ //
265
+ // This mirror only copies the editor's own active state, so a command may
266
+ // only be listed here once core actually tracks it. Emitting the attribute
267
+ // for an untracked command yields a permanent aria-pressed="false" —
268
+ // confidently announcing "not pressed" while the formatting IS applied,
269
+ // which makes announced state DISAGREE with the highlight. That is the exact
270
+ // failure this mirror exists to prevent, so silence beats a wrong answer.
271
+ // - `toggleborder` was already tracked (core checks the editable's
272
+ // rte-toggleborder class); it reads inactive simply when borders are off.
273
+ // - `insertblockquote` was NOT tracked and now is, via a core case added
274
+ // alongside `indent`, which resolves the same way.
275
+ // Both verified on the running editor rather than inferred.
276
+ //
277
+ // `inlinecode`, `pagination`, `typewriter` and `focusmode` match nothing in
278
+ // this build, and now the reason is known rather than assumed: pagination.js
279
+ // and typewriter.js are API-only (togglePageView / toggleTypewriterMode …)
280
+ // and register no toolbar command, and insertcode.js registers `insertcode`,
281
+ // which inserts a block rather than toggling the selection. They are kept —
282
+ // harmless, and correct the moment a toolbar button is added — but they
283
+ // cover nothing today, so do not read this list as evidence of coverage.
284
+ var TOGGLE_CMD = /^(bold|italic|underline|strikethrough|subscript|superscript|justifyleft|justifycenter|justifyright|justifyfull|insertorderedlist|insertunorderedlist|outdent|indent|insertblockquote|toggleborder|inlinecode|trackchanges|typewriter|focusmode|pagination|formattingmarks|linenumbers|permanentpen|rtlui)$/;
285
+
286
+ function syncToggleStates(root) {
287
+ var btns = [].slice.call(root.querySelectorAll('[role="button"][rte-cmd-name]'));
288
+ for (var i = 0; i < btns.length; i++) {
289
+ var cmd = (btns[i].getAttribute("rte-cmd-name") || "").toLowerCase();
290
+ if (!TOGGLE_CMD.test(cmd)) continue;
291
+ var active = btns[i].classList.contains("rte-command-active");
292
+ var pressed = active ? "true" : "false";
293
+ if (btns[i].getAttribute("aria-pressed") !== pressed) btns[i].setAttribute("aria-pressed", pressed);
294
+ }
295
+ }
296
+
297
+ // ------------------------------------------------- 3. aria-expanded
298
+ //
299
+ // Opening a menu inserts an <rte-dropdown-panel> elsewhere in the document
300
+ // and leaves the button untouched, so the button and its panel have to be
301
+ // correlated: remember which popup button was activated, then pair it with
302
+ // the panel that appears.
303
+ function trackPopups(root) {
304
+ var pops = [].slice.call(root.querySelectorAll("[aria-haspopup]"));
305
+ for (var i = 0; i < pops.length; i++) {
306
+ if (!pops[i].hasAttribute("aria-expanded")) pops[i].setAttribute("aria-expanded", "false");
307
+ if (pops[i].__rtePopupBound) continue;
308
+ pops[i].__rtePopupBound = true;
309
+ (function (el) {
310
+ function remember() { popupOwner = el; }
311
+ el.addEventListener("mousedown", remember, true);
312
+ el.addEventListener("keydown", function (e) {
313
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") remember();
314
+ }, true);
315
+ })(pops[i]);
316
+ }
317
+
318
+ if (trackPopups.bound) return;
319
+ trackPopups.bound = true;
320
+ var mo = new MutationObserver(function (records) {
321
+ for (var r = 0; r < records.length; r++) {
322
+ var rec = records[r];
323
+ for (var a = 0; a < rec.addedNodes.length; a++) {
324
+ var n = rec.addedNodes[a];
325
+ if (n.nodeType !== 1 || !isPanel(n)) continue;
326
+ if (popupOwner) {
327
+ popupOwner.setAttribute("aria-expanded", "true");
328
+ openPanels.push({ panel: n, owner: popupOwner });
329
+ popupOwner = null;
330
+ }
331
+ }
332
+ for (var d = 0; d < rec.removedNodes.length; d++) {
333
+ var m = rec.removedNodes[d];
334
+ if (m.nodeType !== 1 || !isPanel(m)) continue;
335
+ for (var k = openPanels.length - 1; k >= 0; k--) {
336
+ if (openPanels[k].panel === m) {
337
+ openPanels[k].owner.setAttribute("aria-expanded", "false");
338
+ openPanels.splice(k, 1);
339
+ }
340
+ }
341
+ }
342
+ }
343
+ });
344
+ mo.observe(document.body, { childList: true, subtree: true });
345
+ observers.push(mo);
346
+ }
347
+ function isPanel(el) {
348
+ var t = (el.tagName || "").toLowerCase();
349
+ return t === "rte-dropdown-panel" || t === "rte-floatpanel" || t === "rte-dialog-float";
350
+ }
351
+
352
+ // Toolbar buttons are rebuilt and re-classed as the selection moves, so the
353
+ // mirrored state has to follow rather than be set once.
354
+ function watch(root) {
355
+ if (root.__rteA11yWatch) return;
356
+ root.__rteA11yWatch = true;
357
+ var pending = null;
358
+ var mo = new MutationObserver(function () {
359
+ if (pending) return;
360
+ pending = setTimeout(function () {
361
+ pending = null;
362
+ syncToggleStates(root);
363
+ applyRovingTabindex(root);
364
+ trackPopups(root);
365
+ }, 60);
366
+ });
367
+ mo.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-disabled"] });
368
+ observers.push(mo);
369
+ }
370
+ }