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