diff --git a/packages/base/src/util/InvisibleMessage.ts b/packages/base/src/util/InvisibleMessage.ts index 3d0206b7acfcb..66f6171275409 100644 --- a/packages/base/src/util/InvisibleMessage.ts +++ b/packages/base/src/util/InvisibleMessage.ts @@ -2,8 +2,14 @@ import InvisibleMessageMode from "../types/InvisibleMessageMode.js"; import getSingletonElementInstance from "./getSingletonElementInstance.js"; import { attachBoot } from "../Boot.js"; -let politeSpan: HTMLElement; -let assertiveSpan: HTMLElement; +type AnnouncementSpans = { + polite: HTMLElement; + assertive: HTMLElement; +}; + +let defaultSpans: AnnouncementSpans; + +const regions: Array<{ container: HTMLElement, spans: AnnouncementSpans }> = []; const setOutOfViewportStyles = (el: HTMLElement) => { el.style.position = "absolute"; @@ -14,13 +20,12 @@ const setOutOfViewportStyles = (el: HTMLElement) => { el.style.pointerEvents = "none"; }; -attachBoot(() => { - if (politeSpan && assertiveSpan) { - return; - } - - politeSpan = document.createElement("span"); - assertiveSpan = document.createElement("span"); +/** + * Creates a pair of off-viewport aria-live spans (polite and assertive) to be used for screen reader announcements. + */ +const createAnnouncementSpans = (): AnnouncementSpans => { + const politeSpan = document.createElement("span"); + const assertiveSpan = document.createElement("span"); politeSpan.classList.add("ui5-invisiblemessage-polite"); assertiveSpan.classList.add("ui5-invisiblemessage-assertive"); @@ -34,10 +39,62 @@ attachBoot(() => { setOutOfViewportStyles(politeSpan); setOutOfViewportStyles(assertiveSpan); - getSingletonElementInstance("ui5-announcement-area").appendChild(politeSpan); - getSingletonElementInstance("ui5-announcement-area").appendChild(assertiveSpan); + return { polite: politeSpan, assertive: assertiveSpan }; +}; + +attachBoot(() => { + if (defaultSpans) { + return; + } + + defaultSpans = createAnnouncementSpans(); + + const announcementArea = getSingletonElementInstance("ui5-announcement-area"); + announcementArea.appendChild(defaultSpans.polite); + announcementArea.appendChild(defaultSpans.assertive); }); +/** + * Registers an element as an aria-live region container. A pair of hidden aria-live spans (polite and assertive) + * is created inside the provided container, and subsequent announcements are routed there while it stays registered. + * + * This is used to render the aria-live region inside a dialog/popover, so that announcements made while a modal + * popup is open (and the screen reader's accessibility tree is scoped to the popup's subtree) are still read out. + * + * @param { HTMLElement } container The element that will host the aria-live spans. + * @public + */ +const registerInvisibleMessageRegion = (container: HTMLElement) => { + if (regions.some(region => region.container === container)) { + return; + } + + const spans = createAnnouncementSpans(); + container.appendChild(spans.polite); + container.appendChild(spans.assertive); + + regions.push({ container, spans }); +}; + +/** + * Deregisters a previously registered aria-live region container, removing its aria-live spans. + * After deregistration, announcements are routed to the next registered region, or to the default + * body-level region if none remain. + * + * @param { HTMLElement } container The element that was previously registered via `registerInvisibleMessageRegion`. + * @public + */ +const deregisterInvisibleMessageRegion = (container: HTMLElement) => { + const index = regions.findIndex(region => region.container === container); + if (index === -1) { + return; + } + + const [region] = regions.splice(index, 1); + region.spans.polite.remove(); + region.spans.assertive.remove(); +}; + /** * Inserts the string into the respective span, depending on the mode provided. * @@ -46,8 +103,16 @@ attachBoot(() => { * @public */ const announce = (message: string, mode: InvisibleMessageMode) => { + let target = defaultSpans; + for (let i = regions.length - 1; i >= 0; i--) { + if (regions[i].container.isConnected) { + target = regions[i].spans; + break; + } + } + // If no type is presented, fallback to polite announcement. - const span = mode === InvisibleMessageMode.Assertive ? assertiveSpan : politeSpan; + const span = mode === InvisibleMessageMode.Assertive ? target.assertive : target.polite; // Set textContent to empty string in order to trigger screen reader's announcement. span.textContent = ""; @@ -67,3 +132,7 @@ const announce = (message: string, mode: InvisibleMessageMode) => { }; export default announce; +export { + registerInvisibleMessageRegion, + deregisterInvisibleMessageRegion, +}; diff --git a/packages/main/src/Popup.ts b/packages/main/src/Popup.ts index 8c3e891f21ff8..dc4deeae6b2bb 100644 --- a/packages/main/src/Popup.ts +++ b/packages/main/src/Popup.ts @@ -27,6 +27,7 @@ import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.j import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; import MediaRange from "@ui5/webcomponents-base/dist/MediaRange.js"; import toLowercaseEnumValue from "@ui5/webcomponents-base/dist/util/toLowercaseEnumValue.js"; +import { registerInvisibleMessageRegion, deregisterInvisibleMessageRegion } from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; import PopupTemplate from "./PopupTemplate.js"; import PopupAccessibleRole from "./types/PopupAccessibleRole.js"; import { addOpenedPopup, removeOpenedPopup } from "./popup-utils/OpenedPopupsRegistry.js"; @@ -367,6 +368,8 @@ abstract class Popup extends UI5Element { this._addOpenedPopup(); + this._registerInvisibleMessageRegion(); + this.classList.add("ui5-popup-opening"); setTimeout(() => { this.classList.remove("ui5-popup-opening"); @@ -601,6 +604,8 @@ abstract class Popup extends UI5Element { this._detachBrowserEvents(); + this._deregisterInvisibleMessageRegion(); + if (!preventRegistryUpdate) { this._removeOpenedPopup(); } @@ -620,6 +625,33 @@ abstract class Popup extends UI5Element { removeOpenedPopup(this); } + /** + * Asks the InvisibleMessage to render its aria-live region inside the popup, so that announcements + * made while the popup is open (and the screen reader's accessibility tree is scoped to the popup) + * are read out. + * + * Only modal popups need this: a screen reader scopes its accessibility tree to a modal popup's + * subtree, so a body-level aria-live region would be silenced. Non-modal popups (e.g. suggestion + * lists) leave focus and the accessibility tree in place, so the default region still works. + * @protected + */ + _registerInvisibleMessageRegion() { + if (this.isModal && this._root) { + registerInvisibleMessageRegion(this._root); + } + } + + /** + * Asks the InvisibleMessage to stop rendering its aria-live region inside the popup, restoring + * the default region. + * @protected + */ + _deregisterInvisibleMessageRegion() { + if (this._root) { + deregisterInvisibleMessageRegion(this._root); + } + } + /** * Returns the focus to the previously focused element * @protected diff --git a/packages/main/test/pages/InvisibleMessageInDialog.html b/packages/main/test/pages/InvisibleMessageInDialog.html new file mode 100644 index 0000000000000..bd4adebdb3670 --- /dev/null +++ b/packages/main/test/pages/InvisibleMessageInDialog.html @@ -0,0 +1,144 @@ + + + +
+ + + +
+ Reproduction for issue #13613.
+ Turn on VoiceOver (Cmd+F5 on macOS), then follow the steps below. With this branch's fix, the UI5 Dialog
+ renders its own aria-live region inside the dialog subtree, so announce() is heard while the dialog is open.
+
This dialog is modal (aria-modal="true"), so VoiceOver scopes its accessibility tree to this subtree.
+Use buttons 3a and 3b below (they stay reachable) to compare the two live regions.
+