@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.
@@ -499,7 +499,13 @@ function RTE_Plugin_AccessibilityChecker() {
499
499
  if (headingText) previousHeadingLevel = level;
500
500
  }
501
501
 
502
- var images = node.querySelectorAll ? node.querySelectorAll("img") : [];
502
+ // querySelectorAll only returns DESCENDANTS, so an image that is
503
+ // itself a top-level block — which is exactly where a pasted or
504
+ // dropped image lands in an empty editor — was never checked, and
505
+ // missing alt text went unreported. The table rule below already
506
+ // guards this case; the image rule did not. Measured 2026-08-27:
507
+ // bare top-level img => 0 issues, the same img inside a <p> => 1.
508
+ var images = tag === "img" ? [node] : (node.querySelectorAll ? node.querySelectorAll("img") : []);
503
509
  for (var imageIndex = 0; imageIndex < images.length; imageIndex++) {
504
510
  var image = images[imageIndex];
505
511
  if (!String(image.getAttribute("alt") || "").replace(/^\s+|\s+$/g, "")) {
@@ -30895,348 +30901,376 @@ function RTE_Plugin_InsertTemplate() {
30895
30901
  };
30896
30902
  }
30897
30903
 
30898
- if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
30899
-
30900
- // 2026-08-02 Keyboard and state accessibility for the editor CHROME.
30901
- //
30902
- // a11yenhance.js names the editing region and dialoga11y.js names the dialogs.
30903
- // This closes what an audit of the running editor found still open — four
30904
- // findings, three of them WCAG Level A:
30905
- //
30906
- // 1. KEYBOARD TRAP (2.1.2, Level A). Tab inside the editing area is
30907
- // preventDefault-ed and inserts spaces, so a keyboard-only or screen
30908
- // reader user who enters the editor can never leave it. Reloading the page
30909
- // is the only way out. This is the most serious kind of accessibility
30910
- // defect: it does not degrade the experience, it ends it.
30911
- //
30912
- // 2. TOGGLE STATE NOT EXPOSED (4.1.2, Level A). Bold, italic, underline and
30913
- // the alignment buttons carry their state in a CSS class
30914
- // (rte-command-active / rte-command-deactive) and nowhere else. Sighted
30915
- // users see a highlighted button; assistive technology is told nothing.
30916
- // Measured: 0 of 6 toggle buttons exposed aria-pressed.
30917
- //
30918
- // 3. POPUP STATE NOT EXPOSED (4.1.2, Level A). Buttons carry
30919
- // aria-haspopup but never aria-expanded, so there is no way to know
30920
- // whether a menu is open. Measured: 0 of 7.
30921
- //
30922
- // 4. EVERY TOOLBAR BUTTON IS A TAB STOP (2.4.3, and plain usability).
30923
- // Measured 53. The ARIA Authoring Practices toolbar pattern is a single
30924
- // tab stop per toolbar with arrow keys moving between buttons. 53 presses
30925
- // of Tab to reach the text you came to write is not operable in any
30926
- // meaningful sense.
30927
- //
30928
- // All four are fixed here rather than in the core, so they ship without a
30929
- // re-obfuscation cycle.
30930
- //
30931
- // Config:
30932
- // config.keyboardA11y = false // opt out entirely
30933
- // config.a11yEscapeHint = "..." // wording appended to the editing area's name
30934
- // config.a11yRovingToolbar = false // keep every button as a tab stop
30935
- RTE_DefaultConfig.plugin_keyboarda11y = RTE_Plugin_KeyboardA11y;
30936
- if (typeof RTE_DefaultConfig.keyboardA11y === "undefined") RTE_DefaultConfig.keyboardA11y = true;
30937
-
30938
- function RTE_Plugin_KeyboardA11y() {
30939
- var obj = this;
30940
- var config, editor;
30941
- var observers = [];
30942
- var popupOwner = null; // last activated [aria-haspopup]
30943
- var openPanels = []; // [{ panel, owner }]
30944
-
30945
- obj.PluginName = "KeyboardA11y";
30946
-
30947
- obj.InitConfig = function (argconfig) { config = argconfig; };
30948
-
30949
- obj.InitEditor = function (argeditor) {
30950
- editor = argeditor;
30951
- if (config.keyboardA11y === false) return;
30952
-
30953
- editor.focusToolbar = function () { return focusFirstToolbarButton(); };
30954
-
30955
- setup();
30956
- try { editor.attachEvent("ready", setup); } catch (e) {}
30957
- // The toolbar is built asynchronously; a deferred pass catches the case
30958
- // where InitEditor runs before it exists.
30959
- setTimeout(setup, 0);
30960
- setTimeout(setup, 400);
30961
- };
30962
-
30963
- function shell() {
30964
- try {
30965
- var ed = editor.getEditable();
30966
- if (!ed) return null;
30967
- var win = ed.ownerDocument.defaultView;
30968
- var node = (win && win.frameElement) ? win.frameElement : ed;
30969
- while (node && node.classList && !node.classList.contains("richtexteditor")) node = node.parentNode;
30970
- return (node && node.classList) ? node : null;
30971
- } catch (e) { return null; }
30972
- }
30973
-
30974
- function setup() {
30975
- var root = shell();
30976
- if (!root) return;
30977
- bindEscapeHatch();
30978
- applyRovingTabindex(root);
30979
- syncToggleStates(root);
30980
- trackPopups(root);
30981
- watch(root);
30982
- }
30983
-
30984
- // ---------------------------------------------------- 1. keyboard trap
30985
- //
30986
- // Tab keeps its editing meaning (indent, next table cell) because that is
30987
- // what writers expect and what every other editor does. The escape is a
30988
- // separate, documented key: Escape leaves the editing area and puts focus
30989
- // on the toolbar, from which Tab continues through the page normally.
30990
- //
30991
- // Escape is only intercepted when nothing is open — a dialog or dropdown
30992
- // must still get its own Escape first, or closing a colour picker would
30993
- // throw the user out of the editor.
30994
- function bindEscapeHatch() {
30995
- var ed;
30996
- try { ed = editor.getEditable(); } catch (e) { return; }
30997
- if (!ed || ed.__rteEscapeHatch) return;
30998
- ed.__rteEscapeHatch = true;
30999
-
31000
- ed.addEventListener("keydown", function (e) {
31001
- if (e.key !== "Escape" && e.keyCode !== 27) return;
31002
- if (anythingOpen()) return; // let the panel close itself
31003
- e.preventDefault();
31004
- e.stopPropagation();
31005
- if (!focusFirstToolbarButton()) focusAfterEditor();
31006
- }, false);
31007
-
31008
- announceEscape(ed);
31009
- }
31010
-
31011
- function anythingOpen() {
31012
- try {
31013
- var panels = document.querySelectorAll("rte-dropdown-panel, rte-dialog-float, rte-floatpanel");
31014
- for (var i = 0; i < panels.length; i++) {
31015
- var s = getComputedStyle(panels[i]);
31016
- if (s.display !== "none" && s.visibility !== "hidden") return true;
31017
- }
31018
- } catch (e) {}
31019
- return false;
31020
- }
31021
-
31022
- // The way out has to be discoverable, or it may as well not exist. The
31023
- // editing region's accessible name is the one thing a screen reader always
31024
- // announces on entry, so the hint goes there rather than into the content.
31025
- function announceEscape(ed) {
31026
- var hint = config.a11yEscapeHint ||
31027
- "Press Escape to leave the editing area.";
31028
- var label = ed.getAttribute("aria-label") || "";
31029
- if (label.indexOf(hint) >= 0) return;
31030
- ed.setAttribute("aria-label", (label ? label.replace(/\s*$/, " ") : "") + hint);
31031
- }
31032
-
31033
- function focusAfterEditor() {
31034
- var root = shell();
31035
- if (!root) return false;
31036
- var all = [].slice.call(document.querySelectorAll(
31037
- 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])'
31038
- )).filter(function (el) {
31039
- return !root.contains(el) && el.offsetParent !== null;
31040
- });
31041
- // The first focusable that follows the editor in document order.
31042
- for (var i = 0; i < all.length; i++) {
31043
- if (root.compareDocumentPosition(all[i]) & Node.DOCUMENT_POSITION_FOLLOWING) {
31044
- all[i].focus();
31045
- return true;
31046
- }
31047
- }
31048
- return false;
31049
- }
31050
-
31051
- // ------------------------------------------------- 4. roving tabindex
31052
- function toolbarsIn(root) {
31053
- return [].slice.call(root.querySelectorAll('[role="toolbar"]'));
31054
- }
31055
- // Toolbars nest — an overflow or ribbon toolbar sits inside the main one.
31056
- // A plain descendant query therefore makes the outer toolbar claim the
31057
- // inner one's buttons, and the two passes fight: the first run measured two
31058
- // tab stops in one toolbar and NONE in the other two, which left those
31059
- // buttons unreachable by keyboard altogether. Each button belongs to its
31060
- // NEAREST toolbar and to no other.
31061
- function buttonsIn(bar) {
31062
- return [].slice.call(bar.querySelectorAll('[role="button"]')).filter(function (b) {
31063
- if (b.getAttribute("aria-disabled") === "true") return false;
31064
- if (b.offsetParent === null) return false; // hidden: not reachable anyway
31065
- return (b.closest && b.closest('[role="toolbar"]')) === bar;
31066
- });
31067
- }
31068
-
31069
- function applyRovingTabindex(root) {
31070
- if (config.a11yRovingToolbar === false) return;
31071
- var bars = toolbarsIn(root);
31072
- for (var i = 0; i < bars.length; i++) {
31073
- (function (bar) {
31074
- var btns = buttonsIn(bar);
31075
- if (!btns.length) return;
31076
- // Exactly one tab stop per toolbar, which is the ARIA pattern.
31077
- var current = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
31078
- for (var j = 0; j < btns.length; j++) btns[j].setAttribute("tabindex", btns[j] === current ? "0" : "-1");
31079
-
31080
- if (bar.__rteRoving) return;
31081
- bar.__rteRoving = true;
31082
- bar.addEventListener("keydown", function (e) {
31083
- var list = buttonsIn(bar);
31084
- var at = list.indexOf(document.activeElement);
31085
- if (at < 0) return;
31086
- var rtl = (bar.closest && bar.closest('[dir="rtl"]')) ? true : false;
31087
- var next = null;
31088
- if (e.key === "ArrowRight") next = list[(at + (rtl ? -1 : 1) + list.length) % list.length];
31089
- else if (e.key === "ArrowLeft") next = list[(at + (rtl ? 1 : -1) + list.length) % list.length];
31090
- else if (e.key === "Home") next = list[0];
31091
- else if (e.key === "End") next = list[list.length - 1];
31092
- else return;
31093
- e.preventDefault();
31094
- for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === next ? "0" : "-1");
31095
- next.focus();
31096
- }, false);
31097
- // Clicking a button makes it the new tab stop, so returning by
31098
- // Tab lands where the user last was.
31099
- bar.addEventListener("focusin", function (e) {
31100
- var list = buttonsIn(bar);
31101
- if (list.indexOf(e.target) < 0) return;
31102
- for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === e.target ? "0" : "-1");
31103
- }, false);
31104
- })(bars[i]);
31105
- }
31106
- }
31107
-
31108
- function focusFirstToolbarButton() {
31109
- var root = shell();
31110
- if (!root) return false;
31111
- var bars = toolbarsIn(root);
31112
- for (var i = 0; i < bars.length; i++) {
31113
- var btns = buttonsIn(bars[i]);
31114
- if (!btns.length) continue;
31115
- var target = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
31116
- target.focus();
31117
- return true;
31118
- }
31119
- return false;
31120
- }
31121
-
31122
- // ------------------------------------------------- 2. aria-pressed
31123
- //
31124
- // The editor already tracks active state — it just keeps it in a class.
31125
- // Mirroring rather than recomputing means the announced state can never
31126
- // disagree with the highlighted button.
31127
- // Command names here must match the TOOLBAR command exactly — the regex is
31128
- // anchored, so a near-miss is a silent no-op that looks like coverage.
31129
- // "blockquote" is one: the command is `insertblockquote`, so the entry
31130
- // written specifically for it never matched and that button ships with no
31131
- // aria-pressed. Found by checking every genuine toggle on the full toolbar
31132
- // rather than the three the published verify page samples.
31133
- //
31134
- // This mirror only copies the editor's own active state, so a command may
31135
- // only be listed here once core actually tracks it. Emitting the attribute
31136
- // for an untracked command yields a permanent aria-pressed="false" —
31137
- // confidently announcing "not pressed" while the formatting IS applied,
31138
- // which makes announced state DISAGREE with the highlight. That is the exact
31139
- // failure this mirror exists to prevent, so silence beats a wrong answer.
31140
- // - `toggleborder` was already tracked (core checks the editable's
31141
- // rte-toggleborder class); it reads inactive simply when borders are off.
31142
- // - `insertblockquote` was NOT tracked and now is, via a core case added
31143
- // alongside `indent`, which resolves the same way.
31144
- // Both verified on the running editor rather than inferred.
31145
- //
31146
- // `inlinecode`, `pagination`, `typewriter` and `focusmode` match nothing in
31147
- // this build, and now the reason is known rather than assumed: pagination.js
31148
- // and typewriter.js are API-only (togglePageView / toggleTypewriterMode …)
31149
- // and register no toolbar command, and insertcode.js registers `insertcode`,
31150
- // which inserts a block rather than toggling the selection. They are kept —
31151
- // harmless, and correct the moment a toolbar button is added — but they
31152
- // cover nothing today, so do not read this list as evidence of coverage.
31153
- 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)$/;
31154
-
31155
- function syncToggleStates(root) {
31156
- var btns = [].slice.call(root.querySelectorAll('[role="button"][rte-cmd-name]'));
31157
- for (var i = 0; i < btns.length; i++) {
31158
- var cmd = (btns[i].getAttribute("rte-cmd-name") || "").toLowerCase();
31159
- if (!TOGGLE_CMD.test(cmd)) continue;
31160
- var active = btns[i].classList.contains("rte-command-active");
31161
- var pressed = active ? "true" : "false";
31162
- if (btns[i].getAttribute("aria-pressed") !== pressed) btns[i].setAttribute("aria-pressed", pressed);
31163
- }
31164
- }
31165
-
31166
- // ------------------------------------------------- 3. aria-expanded
31167
- //
31168
- // Opening a menu inserts an <rte-dropdown-panel> elsewhere in the document
31169
- // and leaves the button untouched, so the button and its panel have to be
31170
- // correlated: remember which popup button was activated, then pair it with
31171
- // the panel that appears.
31172
- function trackPopups(root) {
31173
- var pops = [].slice.call(root.querySelectorAll("[aria-haspopup]"));
31174
- for (var i = 0; i < pops.length; i++) {
31175
- if (!pops[i].hasAttribute("aria-expanded")) pops[i].setAttribute("aria-expanded", "false");
31176
- if (pops[i].__rtePopupBound) continue;
31177
- pops[i].__rtePopupBound = true;
31178
- (function (el) {
31179
- function remember() { popupOwner = el; }
31180
- el.addEventListener("mousedown", remember, true);
31181
- el.addEventListener("keydown", function (e) {
31182
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") remember();
31183
- }, true);
31184
- })(pops[i]);
31185
- }
31186
-
31187
- if (trackPopups.bound) return;
31188
- trackPopups.bound = true;
31189
- var mo = new MutationObserver(function (records) {
31190
- for (var r = 0; r < records.length; r++) {
31191
- var rec = records[r];
31192
- for (var a = 0; a < rec.addedNodes.length; a++) {
31193
- var n = rec.addedNodes[a];
31194
- if (n.nodeType !== 1 || !isPanel(n)) continue;
31195
- if (popupOwner) {
31196
- popupOwner.setAttribute("aria-expanded", "true");
31197
- openPanels.push({ panel: n, owner: popupOwner });
31198
- popupOwner = null;
31199
- }
31200
- }
31201
- for (var d = 0; d < rec.removedNodes.length; d++) {
31202
- var m = rec.removedNodes[d];
31203
- if (m.nodeType !== 1 || !isPanel(m)) continue;
31204
- for (var k = openPanels.length - 1; k >= 0; k--) {
31205
- if (openPanels[k].panel === m) {
31206
- openPanels[k].owner.setAttribute("aria-expanded", "false");
31207
- openPanels.splice(k, 1);
31208
- }
31209
- }
31210
- }
31211
- }
31212
- });
31213
- mo.observe(document.body, { childList: true, subtree: true });
31214
- observers.push(mo);
31215
- }
31216
- function isPanel(el) {
31217
- var t = (el.tagName || "").toLowerCase();
31218
- return t === "rte-dropdown-panel" || t === "rte-floatpanel" || t === "rte-dialog-float";
31219
- }
31220
-
31221
- // Toolbar buttons are rebuilt and re-classed as the selection moves, so the
31222
- // mirrored state has to follow rather than be set once.
31223
- function watch(root) {
31224
- if (root.__rteA11yWatch) return;
31225
- root.__rteA11yWatch = true;
31226
- var pending = null;
31227
- var mo = new MutationObserver(function () {
31228
- if (pending) return;
31229
- pending = setTimeout(function () {
31230
- pending = null;
31231
- syncToggleStates(root);
31232
- applyRovingTabindex(root);
31233
- trackPopups(root);
31234
- }, 60);
31235
- });
31236
- mo.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-disabled"] });
31237
- observers.push(mo);
31238
- }
31239
- }
30904
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
30905
+
30906
+ // 2026-08-02 Keyboard and state accessibility for the editor CHROME.
30907
+ //
30908
+ // a11yenhance.js names the editing region and dialoga11y.js names the dialogs.
30909
+ // This closes what an audit of the running editor found still open — four
30910
+ // findings, three of them WCAG Level A:
30911
+ //
30912
+ // 1. KEYBOARD TRAP (2.1.2, Level A). Tab inside the editing area is
30913
+ // preventDefault-ed and inserts spaces, so a keyboard-only or screen
30914
+ // reader user who enters the editor can never leave it. Reloading the page
30915
+ // is the only way out. This is the most serious kind of accessibility
30916
+ // defect: it does not degrade the experience, it ends it.
30917
+ //
30918
+ // 2. TOGGLE STATE NOT EXPOSED (4.1.2, Level A). Bold, italic, underline and
30919
+ // the alignment buttons carry their state in a CSS class
30920
+ // (rte-command-active / rte-command-deactive) and nowhere else. Sighted
30921
+ // users see a highlighted button; assistive technology is told nothing.
30922
+ // Measured: 0 of 6 toggle buttons exposed aria-pressed.
30923
+ //
30924
+ // 3. POPUP STATE NOT EXPOSED (4.1.2, Level A). Buttons carry
30925
+ // aria-haspopup but never aria-expanded, so there is no way to know
30926
+ // whether a menu is open. Measured: 0 of 7.
30927
+ //
30928
+ // 4. EVERY TOOLBAR BUTTON IS A TAB STOP (2.4.3, and plain usability).
30929
+ // Measured 53. The ARIA Authoring Practices toolbar pattern is a single
30930
+ // tab stop per toolbar with arrow keys moving between buttons. 53 presses
30931
+ // of Tab to reach the text you came to write is not operable in any
30932
+ // meaningful sense.
30933
+ //
30934
+ // All four are fixed here rather than in the core, so they ship without a
30935
+ // re-obfuscation cycle.
30936
+ //
30937
+ // Config:
30938
+ // config.keyboardA11y = false // opt out entirely
30939
+ // config.a11yEscapeHint = "..." // wording appended to the editing area's name
30940
+ // config.a11yEditorLabel = "..." // accessible NAME of the editing area (default "Rich text editor")
30941
+ // config.a11yRovingToolbar = false // keep every button as a tab stop
30942
+ RTE_DefaultConfig.plugin_keyboarda11y = RTE_Plugin_KeyboardA11y;
30943
+ if (typeof RTE_DefaultConfig.keyboardA11y === "undefined") RTE_DefaultConfig.keyboardA11y = true;
30944
+
30945
+ function RTE_Plugin_KeyboardA11y() {
30946
+ var obj = this;
30947
+ var config, editor;
30948
+ var observers = [];
30949
+ var popupOwner = null; // last activated [aria-haspopup]
30950
+ var openPanels = []; // [{ panel, owner }]
30951
+
30952
+ obj.PluginName = "KeyboardA11y";
30953
+
30954
+ obj.InitConfig = function (argconfig) { config = argconfig; };
30955
+
30956
+ obj.InitEditor = function (argeditor) {
30957
+ editor = argeditor;
30958
+ if (config.keyboardA11y === false) return;
30959
+
30960
+ editor.focusToolbar = function () { return focusFirstToolbarButton(); };
30961
+
30962
+ setup();
30963
+ try { editor.attachEvent("ready", setup); } catch (e) {}
30964
+ // The toolbar is built asynchronously; a deferred pass catches the case
30965
+ // where InitEditor runs before it exists.
30966
+ setTimeout(setup, 0);
30967
+ setTimeout(setup, 400);
30968
+ };
30969
+
30970
+ function shell() {
30971
+ try {
30972
+ var ed = editor.getEditable();
30973
+ if (!ed) return null;
30974
+ var win = ed.ownerDocument.defaultView;
30975
+ var node = (win && win.frameElement) ? win.frameElement : ed;
30976
+ while (node && node.classList && !node.classList.contains("richtexteditor")) node = node.parentNode;
30977
+ return (node && node.classList) ? node : null;
30978
+ } catch (e) { return null; }
30979
+ }
30980
+
30981
+ function setup() {
30982
+ var root = shell();
30983
+ if (!root) return;
30984
+ bindEscapeHatch();
30985
+ applyRovingTabindex(root);
30986
+ syncToggleStates(root);
30987
+ trackPopups(root);
30988
+ watch(root);
30989
+ }
30990
+
30991
+ // ---------------------------------------------------- 1. keyboard trap
30992
+ //
30993
+ // Tab keeps its editing meaning (indent, next table cell) because that is
30994
+ // what writers expect and what every other editor does. The escape is a
30995
+ // separate, documented key: Escape leaves the editing area and puts focus
30996
+ // on the toolbar, from which Tab continues through the page normally.
30997
+ //
30998
+ // Escape is only intercepted when nothing is open — a dialog or dropdown
30999
+ // must still get its own Escape first, or closing a colour picker would
31000
+ // throw the user out of the editor.
31001
+ // Bound on the DOCUMENT rather than on the body element. Escape bubbles, so
31002
+ // one listener at document level covers the editing area no matter how the
31003
+ // surface is re-created, and it does not depend on a marker flag pinned to a
31004
+ // particular element surviving. Defensive only: no shipped operation is known
31005
+ // to replace the editing body — setHTML, htmlview, fullscreen, readingmode,
31006
+ // preview and toggleborder all preserve its identity (measured 2026-08-27).
31007
+ // Keeping the binding here costs nothing and removes an assumption, but it
31008
+ // fixes no known live defect; do not describe it as a bug fix.
31009
+ function bindEscapeHatch() {
31010
+ var ed, doc;
31011
+ try { ed = editor.getEditable(); doc = ed && ed.ownerDocument; } catch (e) { return; }
31012
+ if (!ed || !doc) return;
31013
+
31014
+ if (!doc.__rteEscapeHatch) {
31015
+ doc.__rteEscapeHatch = true;
31016
+ doc.addEventListener("keydown", function (e) {
31017
+ if (e.key !== "Escape" && e.keyCode !== 27) return;
31018
+ if (anythingOpen()) return; // let the panel close itself
31019
+ e.preventDefault();
31020
+ e.stopPropagation();
31021
+ if (!focusFirstToolbarButton()) focusAfterEditor();
31022
+ }, false);
31023
+
31024
+ // Deliberately not re-asserting the body's aria-label here. An
31025
+ // earlier version did, on the theory that the editing body could be
31026
+ // replaced underneath us; that mechanism was retracted 2026-08-27
31027
+ // after it turned out to be an instrumentation artefact, and
31028
+ // editor.getEditable() is verified to return the live, connected
31029
+ // body on every shipped path. announceEscape() at the end of this
31030
+ // function is sufficient.
31031
+ }
31032
+
31033
+ announceEscape(ed);
31034
+ }
31035
+
31036
+ function anythingOpen() {
31037
+ try {
31038
+ var panels = document.querySelectorAll("rte-dropdown-panel, rte-dialog-float, rte-floatpanel");
31039
+ for (var i = 0; i < panels.length; i++) {
31040
+ var s = getComputedStyle(panels[i]);
31041
+ if (s.display !== "none" && s.visibility !== "hidden") return true;
31042
+ }
31043
+ } catch (e) {}
31044
+ return false;
31045
+ }
31046
+
31047
+ // The way out has to be discoverable, or it may as well not exist. The
31048
+ // editing region's accessible name is the one thing a screen reader always
31049
+ // announces on entry, so the hint goes there rather than into the content.
31050
+ //
31051
+ // But an instruction is not a NAME. When the host page had not labelled the
31052
+ // editing area, appending the hint to an empty label left the region's
31053
+ // accessible name as nothing but "Press Escape to leave the editing area." —
31054
+ // a screen reader then told the user how to LEAVE a field without ever
31055
+ // saying what it was, and two editors on one page were indistinguishable.
31056
+ // So: a host-supplied label still wins as the name; otherwise we supply a
31057
+ // real one and the hint follows it.
31058
+ function announceEscape(ed) {
31059
+ var hint = config.a11yEscapeHint ||
31060
+ "Press Escape to leave the editing area.";
31061
+ var label = ed.getAttribute("aria-label") || "";
31062
+ if (label.indexOf(hint) >= 0) return;
31063
+ var name = label || config.a11yEditorLabel || "Rich text editor";
31064
+ ed.setAttribute("aria-label", name.replace(/\s*$/, "") + " " + hint);
31065
+ }
31066
+
31067
+ function focusAfterEditor() {
31068
+ var root = shell();
31069
+ if (!root) return false;
31070
+ var all = [].slice.call(document.querySelectorAll(
31071
+ 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])'
31072
+ )).filter(function (el) {
31073
+ return !root.contains(el) && el.offsetParent !== null;
31074
+ });
31075
+ // The first focusable that follows the editor in document order.
31076
+ for (var i = 0; i < all.length; i++) {
31077
+ if (root.compareDocumentPosition(all[i]) & Node.DOCUMENT_POSITION_FOLLOWING) {
31078
+ all[i].focus();
31079
+ return true;
31080
+ }
31081
+ }
31082
+ return false;
31083
+ }
31084
+
31085
+ // ------------------------------------------------- 4. roving tabindex
31086
+ function toolbarsIn(root) {
31087
+ return [].slice.call(root.querySelectorAll('[role="toolbar"]'));
31088
+ }
31089
+ // Toolbars nest — an overflow or ribbon toolbar sits inside the main one.
31090
+ // A plain descendant query therefore makes the outer toolbar claim the
31091
+ // inner one's buttons, and the two passes fight: the first run measured two
31092
+ // tab stops in one toolbar and NONE in the other two, which left those
31093
+ // buttons unreachable by keyboard altogether. Each button belongs to its
31094
+ // NEAREST toolbar and to no other.
31095
+ function buttonsIn(bar) {
31096
+ return [].slice.call(bar.querySelectorAll('[role="button"]')).filter(function (b) {
31097
+ if (b.getAttribute("aria-disabled") === "true") return false;
31098
+ if (b.offsetParent === null) return false; // hidden: not reachable anyway
31099
+ return (b.closest && b.closest('[role="toolbar"]')) === bar;
31100
+ });
31101
+ }
31102
+
31103
+ function applyRovingTabindex(root) {
31104
+ if (config.a11yRovingToolbar === false) return;
31105
+ var bars = toolbarsIn(root);
31106
+ for (var i = 0; i < bars.length; i++) {
31107
+ (function (bar) {
31108
+ var btns = buttonsIn(bar);
31109
+ if (!btns.length) return;
31110
+ // Exactly one tab stop per toolbar, which is the ARIA pattern.
31111
+ var current = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
31112
+ for (var j = 0; j < btns.length; j++) btns[j].setAttribute("tabindex", btns[j] === current ? "0" : "-1");
31113
+
31114
+ if (bar.__rteRoving) return;
31115
+ bar.__rteRoving = true;
31116
+ bar.addEventListener("keydown", function (e) {
31117
+ var list = buttonsIn(bar);
31118
+ var at = list.indexOf(document.activeElement);
31119
+ if (at < 0) return;
31120
+ var rtl = (bar.closest && bar.closest('[dir="rtl"]')) ? true : false;
31121
+ var next = null;
31122
+ if (e.key === "ArrowRight") next = list[(at + (rtl ? -1 : 1) + list.length) % list.length];
31123
+ else if (e.key === "ArrowLeft") next = list[(at + (rtl ? 1 : -1) + list.length) % list.length];
31124
+ else if (e.key === "Home") next = list[0];
31125
+ else if (e.key === "End") next = list[list.length - 1];
31126
+ else return;
31127
+ e.preventDefault();
31128
+ for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === next ? "0" : "-1");
31129
+ next.focus();
31130
+ }, false);
31131
+ // Clicking a button makes it the new tab stop, so returning by
31132
+ // Tab lands where the user last was.
31133
+ bar.addEventListener("focusin", function (e) {
31134
+ var list = buttonsIn(bar);
31135
+ if (list.indexOf(e.target) < 0) return;
31136
+ for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === e.target ? "0" : "-1");
31137
+ }, false);
31138
+ })(bars[i]);
31139
+ }
31140
+ }
31141
+
31142
+ function focusFirstToolbarButton() {
31143
+ var root = shell();
31144
+ if (!root) return false;
31145
+ var bars = toolbarsIn(root);
31146
+ for (var i = 0; i < bars.length; i++) {
31147
+ var btns = buttonsIn(bars[i]);
31148
+ if (!btns.length) continue;
31149
+ var target = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
31150
+ target.focus();
31151
+ return true;
31152
+ }
31153
+ return false;
31154
+ }
31155
+
31156
+ // ------------------------------------------------- 2. aria-pressed
31157
+ //
31158
+ // The editor already tracks active state — it just keeps it in a class.
31159
+ // Mirroring rather than recomputing means the announced state can never
31160
+ // disagree with the highlighted button.
31161
+ // Command names here must match the TOOLBAR command exactly — the regex is
31162
+ // anchored, so a near-miss is a silent no-op that looks like coverage.
31163
+ // "blockquote" is one: the command is `insertblockquote`, so the entry
31164
+ // written specifically for it never matched and that button ships with no
31165
+ // aria-pressed. Found by checking every genuine toggle on the full toolbar
31166
+ // rather than the three the published verify page samples.
31167
+ //
31168
+ // This mirror only copies the editor's own active state, so a command may
31169
+ // only be listed here once core actually tracks it. Emitting the attribute
31170
+ // for an untracked command yields a permanent aria-pressed="false" —
31171
+ // confidently announcing "not pressed" while the formatting IS applied,
31172
+ // which makes announced state DISAGREE with the highlight. That is the exact
31173
+ // failure this mirror exists to prevent, so silence beats a wrong answer.
31174
+ // - `toggleborder` was already tracked (core checks the editable's
31175
+ // rte-toggleborder class); it reads inactive simply when borders are off.
31176
+ // - `insertblockquote` was NOT tracked and now is, via a core case added
31177
+ // alongside `indent`, which resolves the same way.
31178
+ // Both verified on the running editor rather than inferred.
31179
+ //
31180
+ // `inlinecode`, `pagination`, `typewriter` and `focusmode` match nothing in
31181
+ // this build, and now the reason is known rather than assumed: pagination.js
31182
+ // and typewriter.js are API-only (togglePageView / toggleTypewriterMode …)
31183
+ // and register no toolbar command, and insertcode.js registers `insertcode`,
31184
+ // which inserts a block rather than toggling the selection. They are kept —
31185
+ // harmless, and correct the moment a toolbar button is added — but they
31186
+ // cover nothing today, so do not read this list as evidence of coverage.
31187
+ 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)$/;
31188
+
31189
+ function syncToggleStates(root) {
31190
+ var btns = [].slice.call(root.querySelectorAll('[role="button"][rte-cmd-name]'));
31191
+ for (var i = 0; i < btns.length; i++) {
31192
+ var cmd = (btns[i].getAttribute("rte-cmd-name") || "").toLowerCase();
31193
+ if (!TOGGLE_CMD.test(cmd)) continue;
31194
+ var active = btns[i].classList.contains("rte-command-active");
31195
+ var pressed = active ? "true" : "false";
31196
+ if (btns[i].getAttribute("aria-pressed") !== pressed) btns[i].setAttribute("aria-pressed", pressed);
31197
+ }
31198
+ }
31199
+
31200
+ // ------------------------------------------------- 3. aria-expanded
31201
+ //
31202
+ // Opening a menu inserts an <rte-dropdown-panel> elsewhere in the document
31203
+ // and leaves the button untouched, so the button and its panel have to be
31204
+ // correlated: remember which popup button was activated, then pair it with
31205
+ // the panel that appears.
31206
+ function trackPopups(root) {
31207
+ var pops = [].slice.call(root.querySelectorAll("[aria-haspopup]"));
31208
+ for (var i = 0; i < pops.length; i++) {
31209
+ if (!pops[i].hasAttribute("aria-expanded")) pops[i].setAttribute("aria-expanded", "false");
31210
+ if (pops[i].__rtePopupBound) continue;
31211
+ pops[i].__rtePopupBound = true;
31212
+ (function (el) {
31213
+ function remember() { popupOwner = el; }
31214
+ el.addEventListener("mousedown", remember, true);
31215
+ el.addEventListener("keydown", function (e) {
31216
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") remember();
31217
+ }, true);
31218
+ })(pops[i]);
31219
+ }
31220
+
31221
+ if (trackPopups.bound) return;
31222
+ trackPopups.bound = true;
31223
+ var mo = new MutationObserver(function (records) {
31224
+ for (var r = 0; r < records.length; r++) {
31225
+ var rec = records[r];
31226
+ for (var a = 0; a < rec.addedNodes.length; a++) {
31227
+ var n = rec.addedNodes[a];
31228
+ if (n.nodeType !== 1 || !isPanel(n)) continue;
31229
+ if (popupOwner) {
31230
+ popupOwner.setAttribute("aria-expanded", "true");
31231
+ openPanels.push({ panel: n, owner: popupOwner });
31232
+ popupOwner = null;
31233
+ }
31234
+ }
31235
+ for (var d = 0; d < rec.removedNodes.length; d++) {
31236
+ var m = rec.removedNodes[d];
31237
+ if (m.nodeType !== 1 || !isPanel(m)) continue;
31238
+ for (var k = openPanels.length - 1; k >= 0; k--) {
31239
+ if (openPanels[k].panel === m) {
31240
+ openPanels[k].owner.setAttribute("aria-expanded", "false");
31241
+ openPanels.splice(k, 1);
31242
+ }
31243
+ }
31244
+ }
31245
+ }
31246
+ });
31247
+ mo.observe(document.body, { childList: true, subtree: true });
31248
+ observers.push(mo);
31249
+ }
31250
+ function isPanel(el) {
31251
+ var t = (el.tagName || "").toLowerCase();
31252
+ return t === "rte-dropdown-panel" || t === "rte-floatpanel" || t === "rte-dialog-float";
31253
+ }
31254
+
31255
+ // Toolbar buttons are rebuilt and re-classed as the selection moves, so the
31256
+ // mirrored state has to follow rather than be set once.
31257
+ function watch(root) {
31258
+ if (root.__rteA11yWatch) return;
31259
+ root.__rteA11yWatch = true;
31260
+ var pending = null;
31261
+ var mo = new MutationObserver(function () {
31262
+ if (pending) return;
31263
+ pending = setTimeout(function () {
31264
+ pending = null;
31265
+ syncToggleStates(root);
31266
+ applyRovingTabindex(root);
31267
+ trackPopups(root);
31268
+ }, 60);
31269
+ });
31270
+ mo.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-disabled"] });
31271
+ observers.push(mo);
31272
+ }
31273
+ }
31240
31274
 
31241
31275
  if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
31242
31276