From 4441c7fdaf04762431f16a3740e1ab68731c337e Mon Sep 17 00:00:00 2001 From: i518532 Date: Tue, 4 Aug 2026 08:41:28 +0300 Subject: [PATCH 1/6] fix(ui5-popup): announce invisible messages inside open popups Screen readers scope their accessibility tree to a modal popup's subtree while it is open, so announcements written to the default body-level aria-live region are not read out. InvisibleMessage now supports registering per-container aria-live regions. Popup registers its root as a region on open and deregisters it on close, so announce() routes messages into the open popup and they are heard. Fixes #13613 --- packages/base/src/util/InvisibleMessage.ts | 93 +++++++++-- packages/main/src/Popup.ts | 28 ++++ .../test/pages/InvisibleMessageInDialog.html | 145 ++++++++++++++++++ 3 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 packages/main/test/pages/InvisibleMessageInDialog.html 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..7e6428dfc6134 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,29 @@ 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. + * @protected + */ + _registerInvisibleMessageRegion() { + if (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..18a7f0a8e4206 --- /dev/null +++ b/packages/main/test/pages/InvisibleMessageInDialog.html @@ -0,0 +1,145 @@ + + + + + + + + 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 +
+ + + + + From 193faa254a344029ce2c6bb868c01a7ea7527c3f Mon Sep 17 00:00:00 2001 From: i518532 Date: Tue, 4 Aug 2026 09:01:45 +0300 Subject: [PATCH 2/6] fix(ui5-popup): resolve test page imports via bundle global The test page used absolute cross-package src paths (/packages/base/src/...ts) which Rollup could not resolve during the production/test bundle, breaking the build. Use the window["sap-ui-webcomponents-bundle"] global (as other test pages do) to access announce(), passing the mode as a plain string. --- packages/main/test/pages/InvisibleMessageInDialog.html | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/main/test/pages/InvisibleMessageInDialog.html b/packages/main/test/pages/InvisibleMessageInDialog.html index 18a7f0a8e4206..bd4adebdb3670 100644 --- a/packages/main/test/pages/InvisibleMessageInDialog.html +++ b/packages/main/test/pages/InvisibleMessageInDialog.html @@ -93,8 +93,7 @@

InvisibleMessage announcements while a modal Dialog is open

From b94b26680a422aa6d790d621391a93872c55757c Mon Sep 17 00:00:00 2001 From: i518532 Date: Tue, 11 Aug 2026 08:59:32 +0300 Subject: [PATCH 5/6] fix(ui5-popup): make native dialog example register/deregister region The native example called announce() without registering the dialog as an aria-live region, so announcements were silenced under the modal a11y scoping. Expose registerInvisibleMessageRegion and deregisterInvisibleMessageRegion on the test bundle and call them on the native dialog's open/close so announce() routes into the dialog subtree. --- packages/main/src/bundle.common.bootstrap.ts | 4 +++- .../test/pages/InvisibleMessageInDialog.html | 16 ++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) 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 index 1d20d5df19276..bf9129554c2db 100644 --- a/packages/main/test/pages/InvisibleMessageInDialog.html +++ b/packages/main/test/pages/InvisibleMessageInDialog.html @@ -96,8 +96,9 @@

Comparison: native <dialog> opened with show

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. - The framework fix does not apply to native dialogs, so announce() stays silenced here; use it to - confirm the scoping behavior is a platform effect, not a UI5-specific one. + 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.

@@ -109,14 +110,14 @@

Comparison: native <dialog> opened with show

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

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

From 165a3c9cad4aa428195041d0c48e537f34cba6bc Mon Sep 17 00:00:00 2001 From: i518532 Date: Tue, 11 Aug 2026 10:08:36 +0300 Subject: [PATCH 6/6] fix(ui5-popup): gate invisible message region on isModal Register the in-popup aria-live region only for modal popups. A screen reader scopes its accessibility tree to a genuinely modal popup (focus trap / backdrop), which silences the body-level region; non-modal popups such as the 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. Add native , native popover, and native popover + aria-modal comparison cases to the InvisibleMessageInDialog test page to demonstrate that modal scoping (not the bare aria-modal attribute) is what silences the body-level region. --- packages/main/src/Popup.ts | 8 +- .../test/pages/InvisibleMessageInDialog.html | 81 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/main/src/Popup.ts b/packages/main/src/Popup.ts index 0711fcecce794..9242b3d5a8472 100644 --- a/packages/main/src/Popup.ts +++ b/packages/main/src/Popup.ts @@ -630,12 +630,14 @@ abstract class Popup extends UI5Element { * 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 popup that lives in the top layer so a body-level - * aria-live region is silenced while the popup is open. + * 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._root) { + if (this.isModal && this._root) { registerInvisibleMessageRegion(this._root); } } diff --git a/packages/main/test/pages/InvisibleMessageInDialog.html b/packages/main/test/pages/InvisibleMessageInDialog.html index bf9129554c2db..ecfde52160f34 100644 --- a/packages/main/test/pages/InvisibleMessageInDialog.html +++ b/packages/main/test/pages/InvisibleMessageInDialog.html @@ -116,6 +116,51 @@

Comparison: native <dialog> opened with show

+

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