diff --git a/arianotify-polyfill.js b/arianotify-polyfill.js index 297bcbf..5562c7a 100644 --- a/arianotify-polyfill.js +++ b/arianotify-polyfill.js @@ -154,8 +154,6 @@ if ( })(); class LiveRegionCustomElement extends HTMLElement { - #shadowRoot = this.attachShadow({ mode: "closed" }); - connectedCallback() { this.ariaAtomic = "true"; this.style.marginLeft = "-1px"; @@ -174,12 +172,16 @@ if ( */ handleMessage(key = null, message = "") { if (passkey !== key) return; + // The message is written to the element's light DOM (rather than a shadow + // root) because screen readers such as NVDA and VoiceOver do not reliably + // announce aria-live updates that occur inside shadow DOM. + // // This is a hack due to the way the aria live API works. A screen reader // will not read a live region again if the text is the same. Adding a // space character tells the browser that the live region has updated, // which will cause it to read again, but with no audible difference. - if (this.#shadowRoot.textContent == message) message += "\u00A0"; - this.#shadowRoot.textContent = message; + if (this.textContent == message) message += "\u00A0"; + this.textContent = message; } } @@ -206,6 +208,58 @@ if ( AssertiveLiveRegionCustomElement ); + /** + * Eagerly inserts the polite and assertive live regions into the document body + * so that assistive technologies (e.g. VoiceOver, NVDA) register them in the + * accessibility tree before any message is announced. Screen readers routinely + * fail to announce updates to a live region that is created and populated at + * nearly the same time, so the regions must already exist when their text + * content changes. `announce()` reuses these pre-created regions when the + * message targets the document body. + * @returns {void} + */ + function ensureBodyLiveRegions() { + if (!document.body) return; + for (const name of [ + politeLiveRegionCustomElementName, + assertiveLiveRegionCustomElementName, + ]) { + if (!document.body.querySelector(name)) { + document.body.append(document.createElement(name)); + } + } + } + + if (document.body) { + ensureBodyLiveRegions(); + } else { + document.addEventListener("DOMContentLoaded", ensureBodyLiveRegions, { + once: true, + }); + } + + /** + * Installs an `ariaNotify` implementation, taking precedence over a native + * implementation when present. Falls back to assignment if + * `Object.defineProperty` throws (e.g. when a native `ariaNotify` property is + * not configurable), ensuring the polyfill is used instead of the browser's + * native `ariaNotify`. + * @param {typeof Element.prototype | typeof Document.prototype} prototype + * @param {(message: string, options?: { priority?: "high" | "normal" }) => void} value + */ + const installAriaNotify = (prototype, value) => { + try { + Object.defineProperty(prototype, "ariaNotify", { + configurable: true, + writable: true, + value, + }); + } catch { + // @ts-ignore - assignment is a fallback when the property cannot be redefined. + prototype.ariaNotify = value; + } + }; + /** * @param {string} message * @param {object} options @@ -219,11 +273,7 @@ if ( }; if (shouldBypassNativeAriaNotify || !("ariaNotify" in Element.prototype)) { - Object.defineProperty(Element.prototype, "ariaNotify", { - configurable: true, - writable: true, - value: elementAriaNotify, - }); + installAriaNotify(Element.prototype, elementAriaNotify); } /** @@ -239,10 +289,6 @@ if ( }; if (shouldBypassNativeAriaNotify || !("ariaNotify" in Document.prototype)) { - Object.defineProperty(Document.prototype, "ariaNotify", { - configurable: true, - writable: true, - value: documentAriaNotify, - }); + installAriaNotify(Document.prototype, documentAriaNotify); } } diff --git a/tests/bypass-native-arianotify.js b/tests/bypass-native-arianotify.js index f707754..ec5ce72 100644 --- a/tests/bypass-native-arianotify.js +++ b/tests/bypass-native-arianotify.js @@ -1,15 +1,31 @@ // @ts-check -for (const prototype of [Element.prototype, Document.prototype]) { - Object.defineProperty(prototype, "ariaNotify", { - configurable: true, - writable: true, - value() { - throw new Error("Expected tests to use the ariaNotify polyfill"); - }, - }); -} - +// Set the bypass flag first so the polyfill always installs itself, even if +// replacing the (possibly native) `ariaNotify` property below fails. If this +// assignment ran after the loop and the loop threw, the flag would be left +// unset and the polyfill would defer to the browser's native `ariaNotify` — the +// exact thing these tests need to avoid. /** @type {typeof globalThis & {__bypassNativeAriaNotify?: boolean}} */ ( globalThis ).__bypassNativeAriaNotify = true; + +for (const prototype of [Element.prototype, Document.prototype]) { + const value = function () { + throw new Error("Expected tests to use the ariaNotify polyfill"); + }; + try { + Object.defineProperty(prototype, "ariaNotify", { + configurable: true, + writable: true, + value, + }); + } catch { + // `Object.defineProperty` throws if a native `ariaNotify` property is not + // configurable. Fall back to assignment, which still works for writable + // (data) properties, so the guard is installed wherever possible. + try { + // @ts-ignore - `value` intentionally throws when the guard is invoked. + prototype.ariaNotify = value; + } catch {} + } +}