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();
Comment thread
ivoplashkov marked this conversation as resolved.
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