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..9242b3d5a8472 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"; @@ -310,6 +311,7 @@ abstract class Popup extends UI5Element { this._deregisterResizeHandler(); this._detachBrowserEvents(); + this._deregisterInvisibleMessageRegion(); deregisterUI5Element(this); } @@ -367,6 +369,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 +605,8 @@ abstract class Popup extends UI5Element { this._detachBrowserEvents(); + this._deregisterInvisibleMessageRegion(); + if (!preventRegistryUpdate) { this._removeOpenedPopup(); } @@ -620,6 +626,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 are read out. + * + * A screen reader scopes its accessibility tree to a modal popup (aria-modal="true"), so a body-level + * aria-live region is silenced while the popup is open. Non-modal popups (e.g. a ComboBox dropdown) do + * not cause this scoping, so their announcements are still heard from the default body-level region and + * must not be routed into the popup subtree. + * @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/src/bundle.common.bootstrap.ts b/packages/main/src/bundle.common.bootstrap.ts index fd20788fffac3..4ca221dcb971d 100644 --- a/packages/main/src/bundle.common.bootstrap.ts +++ b/packages/main/src/bundle.common.bootstrap.ts @@ -72,7 +72,7 @@ import applyDirection from "@ui5/webcomponents-base/dist/locale/applyDirection.j import { attachDirectionChange } from "@ui5/webcomponents-base/dist/locale/directionChange.js"; import { attachLanguageChange, detachLanguageChange } from "@ui5/webcomponents-base/dist/locale/languageChange.js"; import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; -import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; +import announce, { registerInvisibleMessageRegion, deregisterInvisibleMessageRegion } from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; import { ignoreCustomElements, shouldIgnoreCustomElement } from "@ui5/webcomponents-base/dist/IgnoreCustomElements.js"; import { startMultipleDrag } from "@ui5/webcomponents-base/dist/DragAndDrop.js"; import getElementSelection from "@ui5/webcomponents-base/dist/util/SelectionAssistant.js"; @@ -110,6 +110,8 @@ const testAssets = { }, invisibleMessage: { announce, + registerInvisibleMessageRegion, + deregisterInvisibleMessageRegion, }, getElementSelection, getLocaleData, diff --git a/packages/main/test/pages/InvisibleMessageInDialog.html b/packages/main/test/pages/InvisibleMessageInDialog.html new file mode 100644 index 0000000000000..ecfde52160f34 --- /dev/null +++ b/packages/main/test/pages/InvisibleMessageInDialog.html @@ -0,0 +1,267 @@ + + + + + + + + InvisibleMessage in Dialog (issue #13613) + + + + + + + + + +

InvisibleMessage announcements while a modal Dialog is open

+

+ 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. +

+ +
+ 1. Announce — no dialog open + + 2. Open modal dialog + + + 3a. Announce into a span that lives in <body> (outside the dialog) — silenced by VoiceOver + + + + 3b. Announce via InvisibleMessage.announce() — routed inside the open dialog (FIXED) + +
+ + + + + + + +
+

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.

+
+ 3a. Announce into <body> span (silenced) + 3b. Announce via API (heard — FIXED) +
+
+ Close +
+ +

Comparison: native <dialog> opened with showModal()

+

+ A plain HTML <dialog> opened via showModal() also renders as modal, so VoiceOver + scopes its accessibility tree to the dialog subtree — the same condition that silences a body-level live region. + Here the native dialog is registered as an aria-live region on open (and deregistered on close), so + announce() routes into the dialog's subtree and stays audible. Use 4a vs 4b to compare the + still-silenced body span against the registered region. +

+ +
+ 4. Open native <dialog> (showModal) +
+ + +
+

This is a native <dialog> shown with showModal().

+
+ 4a. Announce into <body> span (silenced) + 4b. Announce via API (heard — dialog registered as region) + Close +
+
+
+ +

Comparison: native popover (top layer, NON-modal) via showPopover()

+

+ A native <div popover> opened with showPopover() renders in the top layer, + just like a modal dialog, but it is non-modal: it does NOT set aria-modal and does NOT + scope VoiceOver's accessibility tree to its subtree. This isolates the question — is the silencing caused by the + top layer, or by modality? Open it, then use 5a: the body-level span announcement should still be heard, + confirming that top-layer alone does not silence — only modal scoping (aria-modal) does. This is why non-modal + popups such as the ComboBox dropdown do not need their own registered region. +

+ +
+ 5. Open native popover (showPopover, non-modal) +
+ +
+

This is a native <div popover> shown with showPopover() — top layer, but non-modal.

+
+ 5a. Announce into <body> span (heard — not modal) + 5b. Announce via API (heard — routed to body region) + Close +
+
+ +

Comparison: native popover (top layer) WITH aria-modal="true"

+

+ Same native <div popover> opened with showPopover(), but now carrying + aria-modal="true". The element behaves non-modally (focus is not trapped), yet it claims + modality to the accessibility tree. If the body-level span (6a) is now silenced — unlike case 5 — + this proves the silencing is triggered by aria-modal, not by the top layer. That is the exact condition + the fix gates on with isModal. +

+ +
+ 6. Open native popover WITH aria-modal (showPopover) +
+ + + + + + +