Skip to content
Open
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
93 changes: 81 additions & 12 deletions packages/base/src/util/InvisibleMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand All @@ -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.
*
Expand All @@ -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 = "";
Expand All @@ -67,3 +132,7 @@ const announce = (message: string, mode: InvisibleMessageMode) => {
};

export default announce;
export {
registerInvisibleMessageRegion,
deregisterInvisibleMessageRegion,
};
32 changes: 32 additions & 0 deletions packages/main/src/Popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -601,6 +604,8 @@ abstract class Popup extends UI5Element {

this._detachBrowserEvents();

this._deregisterInvisibleMessageRegion();

if (!preventRegistryUpdate) {
this._removeOpenedPopup();
}
Expand All @@ -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
Expand Down
144 changes: 144 additions & 0 deletions packages/main/test/pages/InvisibleMessageInDialog.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

<title>InvisibleMessage in Dialog (issue #13613)</title>

<script data-ui5-config type="application/json">
{
"language": "EN"
}
</script>

<script src="%VITE_BUNDLE_PATH%" type="module"></script>

<style>
body {
font-family: var(--sapFontFamily);
padding: 1rem;
background-color: var(--sapBackgroundColor);
}

.row {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 40rem;
}

.note {
color: var(--sapContent_LabelColor);
font-size: 0.875rem;
margin: 0.5rem 0 1rem;
}

.log {
margin-top: 1rem;
padding: 0.5rem;
border: 1px solid var(--sapList_BorderColor);
border-radius: 0.25rem;
min-height: 3rem;
white-space: pre-wrap;
font-family: monospace;
}
</style>
</head>

<body>
<h1>InvisibleMessage announcements while a modal Dialog is open</h1>
<p class="note">
Reproduction for <a href="https://github.com/UI5/webcomponents/issues/13613" target="_blank" rel="noopener">issue #13613</a>.
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 <code>announce()</code> is heard while the dialog is open.
</p>

<div class="row">
<ui5-button id="announceNoDialog" design="Emphasized">1. Announce — no dialog open</ui5-button>

<ui5-button id="openDialog">2. Open modal dialog</ui5-button>

<ui5-button id="announceBodySpan">
3a. Announce into a span that lives in &lt;body&gt; (outside the dialog) — silenced by VoiceOver
</ui5-button>

<ui5-button id="announceApi" design="Positive">
3b. Announce via InvisibleMessage.announce() — routed inside the open dialog (FIXED)
</ui5-button>
</div>

<div class="log" id="log" aria-hidden="true">Log:
</div>

<!-- A live region deliberately placed in <body>, OUTSIDE the dialog, to demonstrate the broken case. -->
<span
id="bodySpan"
aria-live="polite"
role="alert"
style="position:absolute;clip:rect(1px,1px,1px,1px);user-select:none;left:-1000px;top:-1000px;pointer-events:none;"
></span>

<ui5-dialog id="dialog" header-text="Modal dialog">
<div style="padding: 1rem; min-width: 20rem;">
<p>This dialog is modal (aria-modal="true"), so VoiceOver scopes its accessibility tree to this subtree.</p>
<p>Use buttons 3a and 3b below (they stay reachable) to compare the two live regions.</p>
<div style="display:flex; flex-direction:column; gap:0.75rem; margin-top:1rem;">
<ui5-button id="announceBodySpanInner">3a. Announce into &lt;body&gt; span (silenced)</ui5-button>
<ui5-button id="announceApiInner" design="Positive">3b. Announce via API (heard — FIXED)</ui5-button>
</div>
</div>
<ui5-button id="closeDialog" slot="footer" design="Transparent">Close</ui5-button>
</ui5-dialog>

<script type="module">
const { announce } = window["sap-ui-webcomponents-bundle"].invisibleMessage;

const logEl = document.getElementById("log");
let counter = 0;

const log = msg => {
logEl.textContent += `${msg}\n`;
};

const bodySpan = document.getElementById("bodySpan");
const dialog = document.getElementById("dialog");

// 1. announce() with no dialog open — heard.
document.getElementById("announceNoDialog").addEventListener("click", () => {
const msg = `No dialog announcement #${++counter}`;
announce(msg, "Polite");
log(`announce(): "${msg}"`);
});

// 2. open the modal dialog
document.getElementById("openDialog").addEventListener("click", () => {
dialog.open = true;
});
document.getElementById("closeDialog").addEventListener("click", () => {
dialog.open = false;
});

// 3a. write into a live region that lives in <body>, outside the dialog — silenced while modal is open.
const announceBody = () => {
const msg = `Body-span announcement #${++counter}`;
bodySpan.textContent = "";
bodySpan.textContent = msg;
log(`body span (outside dialog): "${msg}" → silenced by VoiceOver while dialog open`);
};
document.getElementById("announceBodySpan").addEventListener("click", announceBody);
document.getElementById("announceBodySpanInner").addEventListener("click", announceBody);

// 3b. announce() via the framework API — with the fix, routed into the open dialog's region.
const announceApi = () => {
const msg = `API announcement #${++counter}`;
announce(msg, "Polite");
log(`announce(): "${msg}" → rendered inside the open dialog (heard)`);
};
document.getElementById("announceApi").addEventListener("click", announceApi);
document.getElementById("announceApiInner").addEventListener("click", announceApi);
</script>
</body>

</html>
Loading