diff --git a/.changelog/5962-fix-aria-notify-transport.md b/.changelog/5962-fix-aria-notify-transport.md
new file mode 100644
index 0000000000..cb56a62e67
--- /dev/null
+++ b/.changelog/5962-fix-aria-notify-transport.md
@@ -0,0 +1,3 @@
+### Added
+
+- 🧪 Added `styleOptions.assistiveTechnologyAnnouncementTransport` to announce via `ariaNotify()` instead of the live region, in PR [#5962](https://github.com/microsoft/BotFramework-WebChat/pull/5962), by [@OEvgeny](https://github.com/OEvgeny)
diff --git a/__tests__/html2/accessibility/ariaNotify/adaptiveCard.inputValue.html b/__tests__/html2/accessibility/ariaNotify/adaptiveCard.inputValue.html
new file mode 100644
index 0000000000..e84be467a3
--- /dev/null
+++ b/__tests__/html2/accessibility/ariaNotify/adaptiveCard.inputValue.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/accessibility/ariaNotify/adaptiveCard.inputValue.html.snap-1.png b/__tests__/html2/accessibility/ariaNotify/adaptiveCard.inputValue.html.snap-1.png
new file mode 100644
index 0000000000..923cc4931b
Binary files /dev/null and b/__tests__/html2/accessibility/ariaNotify/adaptiveCard.inputValue.html.snap-1.png differ
diff --git a/__tests__/html2/accessibility/ariaNotify/heroCard.html b/__tests__/html2/accessibility/ariaNotify/heroCard.html
new file mode 100644
index 0000000000..69e2333565
--- /dev/null
+++ b/__tests__/html2/accessibility/ariaNotify/heroCard.html
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/accessibility/ariaNotify/heroCard.html.snap-1.png b/__tests__/html2/accessibility/ariaNotify/heroCard.html.snap-1.png
new file mode 100644
index 0000000000..0b4f32cecf
Binary files /dev/null and b/__tests__/html2/accessibility/ariaNotify/heroCard.html.snap-1.png differ
diff --git a/__tests__/html2/accessibility/ariaNotify/hostHidesLiveRegion.html b/__tests__/html2/accessibility/ariaNotify/hostHidesLiveRegion.html
new file mode 100644
index 0000000000..c69d6a472c
--- /dev/null
+++ b/__tests__/html2/accessibility/ariaNotify/hostHidesLiveRegion.html
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/accessibility/ariaNotify/hostHidesLiveRegion.html.snap-1.png b/__tests__/html2/accessibility/ariaNotify/hostHidesLiveRegion.html.snap-1.png
new file mode 100644
index 0000000000..ef7cdea98c
Binary files /dev/null and b/__tests__/html2/accessibility/ariaNotify/hostHidesLiveRegion.html.snap-1.png differ
diff --git a/__tests__/html2/accessibility/ariaNotify/message.html b/__tests__/html2/accessibility/ariaNotify/message.html
new file mode 100644
index 0000000000..cdaac1c734
--- /dev/null
+++ b/__tests__/html2/accessibility/ariaNotify/message.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/accessibility/ariaNotify/message.html.snap-1.png b/__tests__/html2/accessibility/ariaNotify/message.html.snap-1.png
new file mode 100644
index 0000000000..4eb5b32b38
Binary files /dev/null and b/__tests__/html2/accessibility/ariaNotify/message.html.snap-1.png differ
diff --git a/__tests__/html2/accessibility/ariaNotify/switchTransport.html b/__tests__/html2/accessibility/ariaNotify/switchTransport.html
new file mode 100644
index 0000000000..aee57b760d
--- /dev/null
+++ b/__tests__/html2/accessibility/ariaNotify/switchTransport.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/accessibility/ariaNotify/switchTransport.html.snap-1.png b/__tests__/html2/accessibility/ariaNotify/switchTransport.html.snap-1.png
new file mode 100644
index 0000000000..ac2714abe1
Binary files /dev/null and b/__tests__/html2/accessibility/ariaNotify/switchTransport.html.snap-1.png differ
diff --git a/__tests__/html2/accessibility/ariaNotify/unsupported.html b/__tests__/html2/accessibility/ariaNotify/unsupported.html
new file mode 100644
index 0000000000..fb3c2ec8bf
--- /dev/null
+++ b/__tests__/html2/accessibility/ariaNotify/unsupported.html
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/accessibility/ariaNotify/unsupported.html.snap-1.png b/__tests__/html2/accessibility/ariaNotify/unsupported.html.snap-1.png
new file mode 100644
index 0000000000..055f902f1d
Binary files /dev/null and b/__tests__/html2/accessibility/ariaNotify/unsupported.html.snap-1.png differ
diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts
index 64662e6eaf..685c3f96ad 100644
--- a/packages/api/src/StyleOptions.ts
+++ b/packages/api/src/StyleOptions.ts
@@ -232,9 +232,26 @@ type StrictStyleOptions = {
/**
* Live region - Accessibility
* New activities will be rendered in the non-visual live region and removed after a certain amount of time. Modify this property to change fade time.
+ * Does not apply when announcing via `ariaNotify()` (`assistiveTechnologyAnnouncementTransport: 'aria-notify'`): they are removed right after they are announced.
*/
internalLiveRegionFadeAfter: number | undefined;
+ /**
+ * Live region - Accessibility
+ * How Web Chat sends announcements to screen readers, such as new messages and errors:
+ *
+ * - `'live-region'` - Renders them in a live region (`aria-live="polite"`) inside Web Chat
+ * - `'aria-notify'` - Calls `ariaNotify()` on the Web Chat root element instead, the live region is not used; falls back to `'live-region'` if the browser does not support `ariaNotify()`
+ *
+ * With `'aria-notify'`, announcements continue when the host page hides only the live region (`.webchat__live-region`) from screen readers.
+ * Like the live region, `ariaNotify()` announces nothing when the Web Chat root element is hidden from screen readers.
+ *
+ * @default 'live-region'
+ *
+ * @deprecated This is an experimental style option and should not be used without understanding its risk.
+ */
+ assistiveTechnologyAnnouncementTransport: 'aria-notify' | 'live-region' | undefined;
+
/**
* Markdown styling
* Parse markdown to ensure carriage return is respected
diff --git a/packages/api/src/defaultStyleOptions.ts b/packages/api/src/defaultStyleOptions.ts
index 15ef71b1eb..9934e99bbb 100644
--- a/packages/api/src/defaultStyleOptions.ts
+++ b/packages/api/src/defaultStyleOptions.ts
@@ -80,6 +80,7 @@ const DEFAULT_OPTIONS: StrictStyleOptions = {
emojiSet: undefined,
// Live region - Accessibility
+ assistiveTechnologyAnnouncementTransport: 'live-region',
internalLiveRegionFadeAfter: 1000,
// Markdown
diff --git a/packages/api/src/normalizeStyleOptions.spec.ts b/packages/api/src/normalizeStyleOptions.spec.ts
new file mode 100644
index 0000000000..0feb8fdc71
--- /dev/null
+++ b/packages/api/src/normalizeStyleOptions.spec.ts
@@ -0,0 +1,58 @@
+/** @jest-environment @happy-dom/jest-environment */
+
+import { scenario } from '@testduet/given-when-then';
+import type NormalizeStyleOptionsType from './normalizeStyleOptions';
+
+let consoleWarn: jest.SpyInstance;
+let normalizeStyleOptions: typeof NormalizeStyleOptionsType;
+
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ // Warns on every call instead of once per page, so each scenario can count its own warnings.
+ jest.mock('@msinternal/botframework-webchat-base/utils', () => ({
+ ...jest.requireActual('@msinternal/botframework-webchat-base/utils'),
+ warnOnce:
+ (...args) =>
+ () =>
+ console.warn(...args)
+ }));
+
+ normalizeStyleOptions = require('./normalizeStyleOptions').default;
+});
+
+afterEach(() => consoleWarn.mockClear());
+
+scenario('styleOptions.assistiveTechnologyAnnouncementTransport', bdd => {
+ bdd
+ .given('not set', () => ({}))
+ .when('normalized', styleOptions => normalizeStyleOptions(styleOptions))
+ .then('should default to "live-region"', (_, { assistiveTechnologyAnnouncementTransport }) =>
+ expect(assistiveTechnologyAnnouncementTransport).toBe('live-region')
+ )
+ .and('should not warn', () => expect(consoleWarn).not.toHaveBeenCalled());
+
+ bdd
+ .given('set to "aria-notify"', () => ({ assistiveTechnologyAnnouncementTransport: 'aria-notify' as const }))
+ .when('normalized', styleOptions => normalizeStyleOptions(styleOptions))
+ .then('should be "aria-notify"', (_, { assistiveTechnologyAnnouncementTransport }) =>
+ expect(assistiveTechnologyAnnouncementTransport).toBe('aria-notify')
+ )
+ .and('should not warn', () => expect(consoleWarn).not.toHaveBeenCalled());
+
+ bdd
+ .given('set to an invalid value', () => ({ assistiveTechnologyAnnouncementTransport: 'auto' }))
+ // @ts-expect-error "auto" is not a valid value.
+ .when('normalized', styleOptions => normalizeStyleOptions(styleOptions))
+ .then('should fall back to "live-region"', (_, { assistiveTechnologyAnnouncementTransport }) =>
+ expect(assistiveTechnologyAnnouncementTransport).toBe('live-region')
+ )
+ .and('should warn once', () =>
+ expect(consoleWarn.mock.calls).toEqual([
+ [
+ '"assistiveTechnologyAnnouncementTransport" must be "aria-notify" or "live-region", will set to "live-region".'
+ ]
+ ])
+ );
+});
diff --git a/packages/api/src/normalizeStyleOptions.ts b/packages/api/src/normalizeStyleOptions.ts
index fdba059aac..73f34af9a5 100644
--- a/packages/api/src/normalizeStyleOptions.ts
+++ b/packages/api/src/normalizeStyleOptions.ts
@@ -1,9 +1,13 @@
import { warnOnce } from '@msinternal/botframework-webchat-base/utils';
-import { is, picklist } from 'valibot';
+import { fallback, is, optional, parse, picklist } from 'valibot';
import defaultStyleOptions from './defaultStyleOptions';
import type { StrictStyleOptions, StyleOptions } from './StyleOptions';
+const assistiveTechnologyAnnouncementTransportInvalidValue = warnOnce(
+ '"assistiveTechnologyAnnouncementTransport" must be "aria-notify" or "live-region", will set to "live-region".'
+);
+
const bubbleImageHeightDeprecation = warnOnce(
'"styleOptions.bubbleImageHeight" has been deprecated. Use "styleOptions.bubbleImageMaxHeight" and "styleOptions.bubbleImageMinHeight" instead. This deprecation migration will be removed on or after 2026-07-05.'
);
@@ -26,6 +30,19 @@ const markdownHeadingLevelStartAtInvalidValue = warnOnce(
const slowConnectionAfterInvalidValue = warnOnce('"slowConnectionAfter" cannot be negative, will set to 0.');
+function normalizeAssistiveTechnologyAnnouncementTransport(
+ assistiveTechnologyAnnouncementTransport: unknown
+): StrictStyleOptions['assistiveTechnologyAnnouncementTransport'] {
+ return parse(
+ fallback(optional(picklist(['aria-notify', 'live-region']), 'live-region'), () => {
+ assistiveTechnologyAnnouncementTransportInvalidValue();
+
+ return 'live-region';
+ }),
+ assistiveTechnologyAnnouncementTransport
+ );
+}
+
// TODO: [P4] We should add a notice for people who want to use "styleSet" instead of "styleOptions".
// "styleSet" is actually CSS stylesheet and it is based on the DOM tree.
// DOM tree may change from time to time, thus, maintaining "styleSet" becomes a constant effort.
@@ -142,6 +159,10 @@ export default function normalizeStyleOptions({
filledOptions.bubbleMessageMinWidth = bubbleMinWidth;
}
+ filledOptions.assistiveTechnologyAnnouncementTransport = normalizeAssistiveTechnologyAnnouncementTransport(
+ filledOptions.assistiveTechnologyAnnouncementTransport
+ );
+
// Same as the type of `markdownHeadingLevelStartAt`.
// eslint-disable-next-line no-magic-numbers
if (!is(picklist([1, 2, 3, 4, 5, 6]), filledOptions.markdownHeadingLevelStartAt)) {
diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx
index 6f403b5f25..9e82278c4c 100644
--- a/packages/component/src/Composer.tsx
+++ b/packages/component/src/Composer.tsx
@@ -94,7 +94,7 @@ const ROOT_STYLE = {
};
const ComposerCoreUI = memo(({ children, nonce, storeDebugAPI }: ComposerCoreUIProps) => {
- const [{ internalLiveRegionFadeAfter }] = useStyleOptions();
+ const [{ assistiveTechnologyAnnouncementTransport, internalLiveRegionFadeAfter }] = useStyleOptions();
const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + '';
const rootRef = useRef(null);
const trackException = useTrackException();
@@ -137,7 +137,12 @@ const ComposerCoreUI = memo(({ children, nonce, storeDebugAPI }: ComposerCoreUIP
-
+
{/* When is finalized, it will be using an independent instance that lives inside . */}
diff --git a/packages/component/src/Utils/activityAltText.ts b/packages/component/src/Utils/activityAltText.ts
index bb7131f8e2..fca220635a 100644
--- a/packages/component/src/Utils/activityAltText.ts
+++ b/packages/component/src/Utils/activityAltText.ts
@@ -1,3 +1,4 @@
+import htmlInlineTagNames from './htmlInlineTagNames';
import textFormatToContentType from './textFormatToContentType';
function walk(root: Node, walker: (node: T) => string[]): string[] {
@@ -15,65 +16,6 @@ function walk(root: Node, walker: (node: T) => string[]): string
return results;
}
-// From https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements
-const HTML_INLINE_TAGS = [
- 'A',
- 'ABBR',
- 'ACRONYM',
- 'AUDIO',
- 'B',
- 'BDI',
- 'BDO',
- 'BIG',
- 'BR',
- 'BUTTON',
- 'CANVAS',
- 'CITE',
- 'CODE',
- 'DATA',
- 'DATALIST',
- 'DEL',
- 'DFN',
- 'EM',
- 'EMBED',
- 'I',
- 'IFRAME',
- 'IMG',
- 'INPUT',
- 'INS',
- 'KBD',
- 'LABEL',
- 'MAP',
- 'MARK',
- 'METER',
- 'NOSCRIPT',
- 'OBJECT',
- 'OUTPUT',
- 'PICTURE',
- 'PROGRESS',
- 'Q',
- 'RUBY',
- 'S',
- 'SAMP',
- 'SCRIPT',
- 'SELECT',
- 'SLOT',
- 'SMALL',
- 'SPAN',
- 'STRONG',
- 'SUB',
- 'SUP',
- 'SVG',
- 'TEMPLATE',
- 'TEXTAREA',
- 'TIME',
- 'U',
- 'TT',
- 'VAR',
- 'VIDEO',
- 'WBR'
-];
-
/**
* Computes all text from the descendants of a given HTML node, such as a document, as flattened array. This is best-effort.
*
@@ -90,7 +32,7 @@ function htmlTextAlternatives(root: Node): string[] {
return [textContent];
} else if (tagName === 'IMG') {
return [node.getAttribute('alt')];
- } else if (!HTML_INLINE_TAGS.includes(tagName)) {
+ } else if (!htmlInlineTagNames.includes(tagName)) {
return ['\n'];
}
});
diff --git a/packages/component/src/Utils/htmlInlineTagNames.ts b/packages/component/src/Utils/htmlInlineTagNames.ts
new file mode 100644
index 0000000000..d0c7198cbb
--- /dev/null
+++ b/packages/component/src/Utils/htmlInlineTagNames.ts
@@ -0,0 +1,60 @@
+// From https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements
+const htmlInlineTagNames: readonly string[] = Object.freeze([
+ 'A',
+ 'ABBR',
+ 'ACRONYM',
+ 'AUDIO',
+ 'B',
+ 'BDI',
+ 'BDO',
+ 'BIG',
+ 'BR',
+ 'BUTTON',
+ 'CANVAS',
+ 'CITE',
+ 'CODE',
+ 'DATA',
+ 'DATALIST',
+ 'DEL',
+ 'DFN',
+ 'EM',
+ 'EMBED',
+ 'I',
+ 'IFRAME',
+ 'IMG',
+ 'INPUT',
+ 'INS',
+ 'KBD',
+ 'LABEL',
+ 'MAP',
+ 'MARK',
+ 'METER',
+ 'NOSCRIPT',
+ 'OBJECT',
+ 'OUTPUT',
+ 'PICTURE',
+ 'PROGRESS',
+ 'Q',
+ 'RUBY',
+ 'S',
+ 'SAMP',
+ 'SCRIPT',
+ 'SELECT',
+ 'SLOT',
+ 'SMALL',
+ 'SPAN',
+ 'STRONG',
+ 'SUB',
+ 'SUP',
+ 'SVG',
+ 'TEMPLATE',
+ 'TEXTAREA',
+ 'TIME',
+ 'U',
+ 'TT',
+ 'VAR',
+ 'VIDEO',
+ 'WBR'
+]);
+
+export default htmlInlineTagNames;
diff --git a/packages/component/src/providers/LiveRegionTwin/private/LiveRegionTwinContainer.tsx b/packages/component/src/providers/LiveRegionTwin/private/LiveRegionTwinContainer.tsx
index 9c4f4e887b..9224bcc26c 100644
--- a/packages/component/src/providers/LiveRegionTwin/private/LiveRegionTwinContainer.tsx
+++ b/packages/component/src/providers/LiveRegionTwin/private/LiveRegionTwinContainer.tsx
@@ -1,7 +1,10 @@
-import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
-import React, { Fragment } from 'react';
-import { literal, object, optional, pipe, readonly, string, union, type InferInput } from 'valibot';
+import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot';
+import React, { Fragment, useEffect, useRef } from 'react';
+import { instance, is, literal, nullable, object, optional, picklist, pipe, readonly, string, union } from 'valibot';
+import refObject from '../../../types/internal/refObject';
+import getAnnouncementText from './getAnnouncementText';
+import { ariaNotifiableSchema } from './isAriaNotifySupported';
import useMarkAllAsRenderedEffect from './useMarkAllAsRenderedEffect';
import useStaticElementEntries from './useStaticElementEntries';
@@ -10,14 +13,17 @@ const liveRegionTwinContainerPropsSchema = pipe(
'aria-label': optional(string()),
'aria-live': union([literal('assertive'), literal('polite')]),
'aria-roledescription': optional(string()),
+ ariaNotifyTargetRef: refObject(nullable(instance(HTMLElement))),
className: optional(string()),
role: optional(string()),
- textElementClassName: optional(string())
+ textElementClassName: optional(string()),
+ // `'aria-notify'` only when the browser supports `ariaNotify()`.
+ transport: picklist(['aria-notify', 'live-region'])
}),
readonly()
);
-type LiveRegionTwinContainerProps = InferInput;
+type LiveRegionTwinContainerProps = InferReactProps;
// This container is marked as private because we assume there is only one instance under the .
function LiveRegionTwinContainer(props: LiveRegionTwinContainerProps) {
@@ -25,37 +31,63 @@ function LiveRegionTwinContainer(props: LiveRegionTwinContainerProps) {
'aria-label': ariaLabel,
'aria-live': ariaLive,
'aria-roledescription': ariaRoleDescription,
+ ariaNotifyTargetRef,
className,
role,
- textElementClassName
+ textElementClassName,
+ transport
} = validateProps(liveRegionTwinContainerPropsSchema, props);
const [staticElementEntries] = useStaticElementEntries();
+ const containerRef = useRef(null);
+ const lastSeenKeyRef = useRef(0);
// We assume there is only one instance under the .
// The assumption made us safe to mark everything is rendered.
// In contrary, if we have 0-to-many at different time, we may falsely mark something as rendered.
useMarkAllAsRenderedEffect();
+ // Keys are increasing: entries with a key above the last seen one are new, in queue order, and are notified once, also when React runs effects twice.
+ // Entries are marked as seen in both transports, so switching to `ariaNotify()` does not repeat what the live region has read.
+ useEffect(() => {
+ const { current: target } = ariaNotifyTargetRef;
+
+ staticElementEntries.forEach(({ key }, index) => {
+ if (key > lastSeenKeyRef.current) {
+ lastSeenKeyRef.current = key;
+
+ if (transport === 'aria-notify') {
+ // When notifying, every entry is rendered in its own element, in queue order.
+ const text = getAnnouncementText(containerRef.current.children.item(index));
+
+ text && is(ariaNotifiableSchema, target) && target.ariaNotify(text, { priority: 'normal' });
+ }
+ }
+ });
+ }, [ariaNotifyTargetRef, staticElementEntries, transport]);
+
+ // When notifying, entries are only rendered to compute their text, and removed right after: the container has no live semantics and is hidden from screen readers, so each entry is announced once.
return (
- {staticElementEntries.map(({ element, key }) => {
- if (typeof element === 'string') {
- return (
-
- {element}
-
- );
- }
-
- return
{element} ;
- })}
+ {staticElementEntries.map(({ element, key }) =>
+ transport === 'aria-notify' ? (
+
{element}
+ ) : typeof element === 'string' ? (
+
+ {element}
+
+ ) : (
+
{element}
+ )
+ )}
);
}
diff --git a/packages/component/src/providers/LiveRegionTwin/private/createLiveRegionTwinComposer.tsx b/packages/component/src/providers/LiveRegionTwin/private/createLiveRegionTwinComposer.tsx
index 7a59585c1f..2972eb28d1 100644
--- a/packages/component/src/providers/LiveRegionTwin/private/createLiveRegionTwinComposer.tsx
+++ b/packages/component/src/providers/LiveRegionTwin/private/createLiveRegionTwinComposer.tsx
@@ -1,14 +1,26 @@
import { hooks } from 'botframework-webchat-api';
-import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type Context, type ReactNode } from 'react';
+import React, {
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type Context,
+ type ReactNode,
+ type RefObject
+} from 'react';
import { useRefFrom } from 'use-ref-from';
import { type LiveRegionTwinContextType } from './LiveRegionTwinComposer';
import LiveRegionTwinContainer from './LiveRegionTwinContainer';
+import isAriaNotifySupported from './isAriaNotifySupported';
import type { StaticElement, StaticElementEntry } from './types';
const { usePonyfill } = hooks;
+const DEFAULT_ANNOUNCEMENT_TRANSPORT = 'live-region';
const DEFAULT_ARIA_LIVE = 'polite';
const DEFAULT_FADE_AFTER = 1000;
@@ -22,6 +34,17 @@ type LiveRegionTwinComposerProps = Readonly<{
/** Optional "aria-roledescription" attribute for the live region twin container. */
'aria-roledescription'?: string;
+ /**
+ * How static elements are announced, defaults to `'live-region'`:
+ *
+ * - `'live-region'` - rendered in the container with the `aria-live` attribute
+ * - `'aria-notify'` - their text is sent via `ariaNotify()` on the element of `ariaNotifyTargetRef`, the container has no live semantics; `'live-region'` if the element does not support `ariaNotify()`
+ */
+ announcementTransport?: 'aria-notify' | 'live-region' | undefined;
+
+ /** Element to call `ariaNotify()` on, such as the root element of Web Chat. */
+ ariaNotifyTargetRef: RefObject;
+
/** Optional "className" attribute for the live region twin container. */
className?: string;
@@ -31,6 +54,8 @@ type LiveRegionTwinComposerProps = Readonly<{
* When lowering this value, make sure screen reader can continue to pick up new static elements before fading out.
*
* If this prop is updated, it will be reflected in next queueing elements.
+ *
+ * Does not apply when notifying via `ariaNotify()`: static elements are removed right after they are announced.
*/
fadeAfter?: number;
@@ -45,7 +70,7 @@ type LiveRegionTwinComposerProps = Readonly<{
/**
* Live region twin is an UI component for queueing texts or elements to the screen reader using
- * a container element with `aria-live` attribute set.
+ * a container element with `aria-live` attribute set, or `ariaNotify()` (see `announcementTransport`).
*
* After the text is rendered and queued, it will be removed to reduce burden on the DOM tree.
* Currently, we assume the assistive technologies should pick up the text within 1 second of rendering.
@@ -56,9 +81,11 @@ type LiveRegionTwinComposerProps = Readonly<{
*/
const createLiveRegionTwinComposer = ({ Provider }: Context) => {
const LiveRegionTwinComposer = ({
+ announcementTransport = DEFAULT_ANNOUNCEMENT_TRANSPORT,
'aria-label': ariaLabel,
'aria-live': ariaLive = DEFAULT_ARIA_LIVE,
'aria-roledescription': ariaRoleDescription,
+ ariaNotifyTargetRef,
children,
className,
fadeAfter = DEFAULT_FADE_AFTER,
@@ -67,10 +94,14 @@ const createLiveRegionTwinComposer = ({ Provider }: Context {
const [{ clearTimeout, setTimeout }] = usePonyfill();
const [staticElementEntries, setStaticElementEntries] = useState([]);
- const fadeAfterRef = useRefFrom(fadeAfter);
const markAllAsRenderedTimeoutIdRef = useRef();
const nextKeyRef = useRef(1);
+ // Falls back to the live region when the browser does not support `ariaNotify()`.
+ const transport =
+ announcementTransport === 'aria-notify' && isAriaNotifySupported() ? 'aria-notify' : 'live-region';
+ // When notifying, static elements are only rendered to compute the text to announce: no screen reader reads them, so they are removed right after.
+ const fadeAfterRef = useRefFrom(transport === 'aria-notify' ? 0 : fadeAfter);
const staticElementEntriesRef = useRefFrom(staticElementEntries);
// This function is called by an effect hook `useMarkAllAsRenderedEffect`, it must be designed with converging in mind.
@@ -138,9 +169,11 @@ const createLiveRegionTwinComposer = ({ Provider }: Context
{children}
diff --git a/packages/component/src/providers/LiveRegionTwin/private/getAnnouncementText.ts b/packages/component/src/providers/LiveRegionTwin/private/getAnnouncementText.ts
new file mode 100644
index 0000000000..3837c50966
--- /dev/null
+++ b/packages/component/src/providers/LiveRegionTwin/private/getAnnouncementText.ts
@@ -0,0 +1,105 @@
+import htmlInlineTagNames from '../../../Utils/htmlInlineTagNames';
+
+// Inputs without a text value: checkboxes and radio buttons are named by their wrapping , which is read as text.
+const INPUT_TYPES_WITHOUT_TEXT_VALUE: readonly string[] = Object.freeze(['checkbox', 'file', 'radio']);
+
+// Inline elements that screen readers read as their own items, e.g. buttons next to each other: "USA Japan", not "USAJapan".
+const SEPARATED_INLINE_TAG_NAMES: readonly string[] = Object.freeze([
+ 'BR',
+ 'BUTTON',
+ 'IMG',
+ 'INPUT',
+ 'SELECT',
+ 'TEXTAREA'
+]);
+
+const SKIPPED_TAG_NAMES: readonly string[] = Object.freeze(['NOSCRIPT', 'SCRIPT', 'STYLE', 'TEMPLATE']);
+
+function isElement(node: Node): node is Element {
+ return node.nodeType === Node.ELEMENT_NODE;
+}
+
+function isInputElement(element: Element): element is HTMLInputElement {
+ return element.tagName === 'INPUT';
+}
+
+function isSelectElement(element: Element): element is HTMLSelectElement {
+ return element.tagName === 'SELECT';
+}
+
+function isTextAreaElement(element: Element): element is HTMLTextAreaElement {
+ return element.tagName === 'TEXTAREA';
+}
+
+// Name from `aria-labelledby`, then `aria-label`, as screen readers read controls and images in the live region.
+function getLabel(element: Element): string {
+ return (
+ (element.getAttribute('aria-labelledby') || '')
+ .split(/\s+/u)
+ .map(id => (id && element.ownerDocument.getElementById(id)?.textContent) || '')
+ .join(' ')
+ .trim() ||
+ element.getAttribute('aria-label')?.trim() ||
+ ''
+ );
+}
+
+function getControlText(element: Element, label: string): string {
+ const name = label || element.getAttribute('placeholder') || element.getAttribute('title') || '';
+ const value = isInputElement(element)
+ ? INPUT_TYPES_WITHOUT_TEXT_VALUE.includes(element.type)
+ ? ''
+ : element.value
+ : isSelectElement(element)
+ ? Array.from(element.selectedOptions, ({ text }) => text).join(' ')
+ : isTextAreaElement(element)
+ ? element.value
+ : '';
+
+ // A named by its selected option (`aria-labelledby`) is read once.
+ return value === name ? name : `${name} ${value}`;
+}
+
+function walk(node: Node, texts: string[]): void {
+ if (node.nodeType === Node.TEXT_NODE) {
+ texts.push(node.textContent || '');
+ } else if (
+ isElement(node) &&
+ node.getAttribute('aria-hidden') !== 'true' &&
+ !node.hasAttribute('hidden') &&
+ !SKIPPED_TAG_NAMES.includes(node.tagName) &&
+ !(isInputElement(node) && node.type === 'hidden')
+ ) {
+ const { tagName } = node;
+ const label = getLabel(node);
+ const separator = htmlInlineTagNames.includes(tagName) && !SEPARATED_INLINE_TAG_NAMES.includes(tagName) ? '' : ' ';
+
+ texts.push(separator);
+
+ if (tagName === 'IMG') {
+ texts.push(label || node.getAttribute('alt') || '');
+ } else if (tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA') {
+ texts.push(getControlText(node, label));
+ } else if ((tagName === 'A' || tagName === 'BUTTON') && label) {
+ texts.push(label);
+ } else {
+ node.childNodes.forEach(child => walk(child, texts));
+ }
+
+ texts.push(separator);
+ }
+}
+
+/**
+ * Computes the text that a screen reader reads from a live region entry, so it can be sent via `ariaNotify()`.
+ *
+ * Best-effort: reads text, image alt text, names and values of form controls, and skips `aria-hidden` and hidden subtrees.
+ * State, such as "checked", is not read: it would need localized words.
+ */
+export default function getAnnouncementText(element: Element): string {
+ const texts: string[] = [];
+
+ walk(element, texts);
+
+ return texts.join('').replace(/\s+/gu, ' ').trim();
+}
diff --git a/packages/component/src/providers/LiveRegionTwin/private/isAriaNotifySupported.ts b/packages/component/src/providers/LiveRegionTwin/private/isAriaNotifySupported.ts
new file mode 100644
index 0000000000..c9f75e2b4b
--- /dev/null
+++ b/packages/component/src/providers/LiveRegionTwin/private/isAriaNotifySupported.ts
@@ -0,0 +1,15 @@
+import { function_, is, object } from 'valibot';
+
+// `ariaNotify()` is not in the DOM typings of TypeScript yet.
+const ariaNotifiableSchema = object({ ariaNotify: function_() });
+
+let ariaNotifySupported: boolean | undefined;
+
+// Support is a property of the browser engine: it cannot change while rendering, and is the same in iframes and popup windows Web Chat may render into.
+// Checked on first use rather than on load, because pages and tests may polyfill or remove `ariaNotify()` after Web Chat loads; cached, so the transport is decided on the first render.
+export default function isAriaNotifySupported(): boolean {
+ return (ariaNotifySupported ??=
+ typeof globalThis.Element === 'function' && is(ariaNotifiableSchema, globalThis.Element.prototype));
+}
+
+export { ariaNotifiableSchema };