Skip to content
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,
};
33 changes: 33 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 @@ -310,6 +311,7 @@ abstract class Popup extends UI5Element {

this._deregisterResizeHandler();
this._detachBrowserEvents();
this._deregisterInvisibleMessageRegion();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You register it on open and deregister on close, why is this not in the if statement above?

deregisterUI5Element(this);
}

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -601,6 +605,8 @@ abstract class Popup extends UI5Element {

this._detachBrowserEvents();

this._deregisterInvisibleMessageRegion();
Comment thread
ivoplashkov marked this conversation as resolved.

if (!preventRegistryUpdate) {
this._removeOpenedPopup();
}
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/main/src/bundle.common.bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -110,6 +110,8 @@ const testAssets = {
},
invisibleMessage: {
announce,
registerInvisibleMessageRegion,
deregisterInvisibleMessageRegion,
},
getElementSelection,
getLocaleData,
Expand Down
Loading
Loading