Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 60 additions & 14 deletions arianotify-polyfill.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,6 @@ if (
})();

class LiveRegionCustomElement extends HTMLElement {
#shadowRoot = this.attachShadow({ mode: "closed" });

connectedCallback() {
this.ariaAtomic = "true";
this.style.marginLeft = "-1px";
Expand All @@ -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;
}
}

Expand All @@ -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
Expand All @@ -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);
}

/**
Expand All @@ -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);
}
}
36 changes: 26 additions & 10 deletions tests/bypass-native-arianotify.js
Original file line number Diff line number Diff line change
@@ -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 {}
}
}
Loading