diff --git a/packages/eui/changelogs/upcoming/8916.md b/packages/eui/changelogs/upcoming/8916.md new file mode 100644 index 00000000000..baf55826fba --- /dev/null +++ b/packages/eui/changelogs/upcoming/8916.md @@ -0,0 +1,5 @@ +**Accessibility** + +- Added a new beta `EuiLiveAnnouncer` component which supports `aria-live` announcements on mount +- Added `announceOnMount` prop on `EuiCallOut` to support announcing its content on mount + diff --git a/packages/eui/src/components/accessibility/index.ts b/packages/eui/src/components/accessibility/index.ts index 12a82997e7b..2fc2ec54899 100644 --- a/packages/eui/src/components/accessibility/index.ts +++ b/packages/eui/src/components/accessibility/index.ts @@ -12,3 +12,4 @@ export { EuiScreenReaderOnly, euiScreenReaderOnly } from './screen_reader_only'; export type { EuiScreenReaderOnlyProps } from './screen_reader_only'; export { EuiSkipLink } from './skip_link'; export type { EuiSkipLinkProps } from './skip_link'; +export { type EuiLiveAnnouncerProps, EuiLiveAnnouncer } from './live_announcer'; diff --git a/packages/eui/src/components/accessibility/live_announcer/__snapshots__/live_announcer.test.tsx.snap b/packages/eui/src/components/accessibility/live_announcer/__snapshots__/live_announcer.test.tsx.snap new file mode 100644 index 00000000000..4fa87f26e38 --- /dev/null +++ b/packages/eui/src/components/accessibility/live_announcer/__snapshots__/live_announcer.test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiLiveAnnouncer renders screen reader content when active 1`] = ` +
+ You have new notifications. +
+`; diff --git a/packages/eui/src/components/accessibility/live_announcer/index.ts b/packages/eui/src/components/accessibility/live_announcer/index.ts new file mode 100644 index 00000000000..2704b9ff375 --- /dev/null +++ b/packages/eui/src/components/accessibility/live_announcer/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './live_announcer'; diff --git a/packages/eui/src/components/accessibility/live_announcer/live_announcer.stories.tsx b/packages/eui/src/components/accessibility/live_announcer/live_announcer.stories.tsx new file mode 100644 index 00000000000..c24c1f60e04 --- /dev/null +++ b/packages/eui/src/components/accessibility/live_announcer/live_announcer.stories.tsx @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactNode, useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { EuiLiveAnnouncer, EuiLiveAnnouncerProps } from './live_announcer'; +import { EuiFlexGroup, EuiFlexItem } from '../../flex'; +import { EuiButton } from '../../button'; +import { EuiCodeBlock } from '../../code'; +import { EuiSpacer } from '../../spacer'; +import { EuiCallOut } from '../../call_out'; +import { EuiFlyout, EuiFlyoutBody } from '../../flyout'; + +const meta: Meta = { + title: 'Utilities/EuiLiveAnnouncer', + component: EuiLiveAnnouncer, + args: { + // Component defaults + role: 'status', + isActive: true, + }, + parameters: { + loki: { + // There are no visual elements to test + skip: true, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + parameters: { + codeSnippet: { + snippet: ` + {message} + `, + }, + }, + args: { + children: 'You have new notifications', + }, + render: function Render(args) { + const { children, ...rest } = args; + const [announcement, setAnnouncement] = useState(children); + const [isAnnouncementShown, setAnnouncementShown] = useState(false); + + useEffect(() => { + setAnnouncement(children); + }, [children]); + + const updateAnnouncement = () => { + setAnnouncement( + `You have ${Math.floor(Math.random() * 1000)} new notifications.` + ); + }; + + return ( + + + setAnnouncementShown((shown) => !shown)}> + Toggle announcement + + + + + {isAnnouncementShown && ( + <> + + Update announcement + + + {announcement} + {announcement} + + )} + + + ); + }, +}; + +export const WithinFlyouts: Story = { + parameters: { + codeSnippet: { + skip: true, + }, + }, + args: { + children: 'You have new notifications', + }, + render: function Render(args) { + const { children, ...rest } = args; + const [isFlyoutOpen, setFlyoutOpen] = useState(false); + const [announcement, setAnnouncement] = useState(children); + const [isShown, setShown] = useState(false); + + useEffect(() => { + setAnnouncement(children); + }, [children]); + + const updateAnnouncement = () => { + setAnnouncement( + `You have ${Math.floor(Math.random() * 1000)} new notifications.` + ); + }; + + const content = ( + <> +
+ + Update announcement + + + {announcement} + {announcement} +
+ + setShown((show) => !show)}> + Toggle CallOut + + {isShown && ( + <> + + setShown(false)} + title="Important notification!" + > + {/* long text is for testing clearTimeout functionality */} + + Lorem Ipsum is simply dummy text of the printing and typesetting + industry. Lorem Ipsum has been the industry's standard dummy + text ever since the 1500s, when an unknown printer took a galley + of type and scrambled it to make a type specimen book. It has + survived not only five centuries, but also the leap into + electronic typesetting, remaining essentially unchanged. It was + popularised in the 1960s with the release of Letraset sheets + containing Lorem Ipsum passages, and more recently with desktop + publishing software like Aldus PageMaker including versions of + Lorem Ipsum. + + + + )} + + ); + + return ( + <> + setFlyoutOpen((open) => !open)}> + Toggle flyout + + + {isFlyoutOpen && ( + setFlyoutOpen(false)}> + {content} + + )} + + ); + }, +}; diff --git a/packages/eui/src/components/accessibility/live_announcer/live_announcer.test.tsx b/packages/eui/src/components/accessibility/live_announcer/live_announcer.test.tsx new file mode 100644 index 00000000000..518dbb50531 --- /dev/null +++ b/packages/eui/src/components/accessibility/live_announcer/live_announcer.test.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactElement } from 'react'; +import { act } from '@testing-library/react'; +import { screen, render } from '../../../test/rtl'; + +import { EuiLiveAnnouncer } from './live_announcer'; + +const content = 'You have new notifications.'; + +const renderComponent = (component: ReactElement) => { + const testArgs = render(component); + + act(() => { + jest.advanceTimersByTime(50); + }); + + return testArgs; +}; + +describe('EuiLiveAnnouncer', () => { + jest.useFakeTimers(); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('renders screen reader content when active', () => { + const { container } = renderComponent( + {content} + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders `children` as string correctly', () => { + renderComponent({content}); + + const region = screen.getByRole('status'); + expect(region).toHaveAttribute('aria-live', 'polite'); + expect(region).toHaveAttribute('aria-atomic', 'true'); + expect(region).toHaveTextContent('You have new notifications.'); + }); + + it('renders `children` as ReactNode correctly', () => { + renderComponent({content}); + + const region = screen.getByRole('status').firstChild; + expect(region).toHaveTextContent('You have new notifications.'); + }); + + it('clears the message after `clearAfterMs`', () => { + renderComponent( + {content} + ); + const region = screen.getByRole('status'); + expect(region).toHaveTextContent('You have new notifications.'); + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(region).toHaveTextContent(''); + }); + + it('does not clear the message if `clearAfterMs=false`', () => { + renderComponent( + {content} + ); + const region = screen.getByRole('status'); + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(region).toHaveTextContent('You have new notifications.'); + }); + + it('sets `aria-live` to off when `isActive=false`', () => { + renderComponent( + {content} + ); + const region = screen.getByRole('status'); + expect(region).toHaveAttribute('aria-live', 'off'); + }); + + it('sets custom `role` and `aria-live`', () => { + renderComponent( + + {content} + + ); + const region = screen.getByRole('alert'); + expect(region).toHaveAttribute('aria-live', 'assertive'); + }); + + it('updates the message when `children` change', () => { + const { rerender } = renderComponent( + {content} + ); + const region = screen.getByRole('status'); + expect(region).toHaveTextContent('You have new notifications.'); + rerender( + You have additional notifications. + ); + expect(region).toHaveTextContent('You have additional notifications.'); + }); +}); diff --git a/packages/eui/src/components/accessibility/live_announcer/live_announcer.tsx b/packages/eui/src/components/accessibility/live_announcer/live_announcer.tsx new file mode 100644 index 00000000000..8337198a217 --- /dev/null +++ b/packages/eui/src/components/accessibility/live_announcer/live_announcer.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { + FunctionComponent, + ReactNode, + useEffect, + useState, + isValidElement, +} from 'react'; + +import { EuiScreenReaderOnly } from '../screen_reader_only'; +import { EuiScreenReaderLiveProps } from '../screen_reader_live'; + +export type EuiLiveAnnouncerProps = Omit< + EuiScreenReaderLiveProps, + 'focusRegionOnTextChange' +> & { + /** + * Sets a delay in ms before the live region is cleared. + * The message will still be read by screen readers even if it's cleared in between. + * + * @default 2000 + */ + clearAfterMs?: number | false; +}; + +export const EuiLiveAnnouncer: FunctionComponent = ({ + children, + clearAfterMs = 2000, + isActive = true, + role = 'status', + 'aria-live': ariaLive = 'polite', + ...rest +}) => { + const [content, setContent] = useState(''); + const [isMounted, setMounted] = useState(false); + + useEffect(() => { + if (!isMounted) { + setTimeout(() => { + // set isMounted with a small delay to trigger an render update after first render + setMounted(true); + }, 50); + } + + return () => { + setMounted(false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + let timeout: NodeJS.Timeout | undefined; + + if (children) { + setContent(isValidElement(children) ? children : <>{children}); + } + + if (clearAfterMs) { + timeout = setTimeout(() => { + setContent(''); + }, clearAfterMs); + } + + return () => { + if (timeout) { + clearTimeout(timeout); + } + }; + }, [children, clearAfterMs]); + + return ( + +
+ {isMounted && content} +
+
+ ); +}; diff --git a/packages/eui/src/components/accessibility/screen_reader_live/screen_reader_live.stories.tsx b/packages/eui/src/components/accessibility/screen_reader_live/screen_reader_live.stories.tsx index f2232bd174b..af5e0ff223d 100644 --- a/packages/eui/src/components/accessibility/screen_reader_live/screen_reader_live.stories.tsx +++ b/packages/eui/src/components/accessibility/screen_reader_live/screen_reader_live.stories.tsx @@ -6,12 +6,18 @@ * Side Public License, v 1. */ +import React, { ReactNode, useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { EuiScreenReaderLive, EuiScreenReaderLiveProps, } from './screen_reader_live'; +import { EuiFlexGroup, EuiFlexItem } from '../../flex'; +import { EuiButton } from '../../button'; +import { EuiCodeBlock } from '../../code'; +import { EuiSpacer } from '../../spacer'; +import { EuiFlyout, EuiFlyoutBody } from '../../flyout'; const meta: Meta = { title: 'Utilities/EuiScreenReaderLive', @@ -34,7 +40,107 @@ export default meta; type Story = StoryObj; export const Playground: Story = { + parameters: { + codeSnippet: { + snippet: ` + {message} + `, + }, + }, args: { children: 'You have new notifications', }, + render: function Render(args) { + const { children, ...rest } = args; + const [announcement, setAnnouncement] = useState(children); + const [isAnnouncementShown, setAnnouncementShown] = useState(false); + + useEffect(() => { + setAnnouncement(children); + }, [children]); + + const updateAnnouncement = () => { + setAnnouncement( + `You have ${Math.floor(Math.random() * 1000)} new notifications.` + ); + }; + + return ( + + + setAnnouncementShown((shown) => !shown)}> + Toggle announcement + + + + + {isAnnouncementShown && ( + <> + + Update announcement + + + {announcement} + + {announcement} + + + )} + + + ); + }, +}; + +export const WithinFlyouts: Story = { + parameters: { + codeSnippet: { + skip: true, + }, + }, + args: { + children: 'You have new notifications', + }, + render: function Render(args) { + const { children, ...rest } = args; + const [isFlyoutOpen, setFlyoutOpen] = useState(false); + const [announcement, setAnnouncement] = useState(children); + + useEffect(() => { + setAnnouncement(children); + }, [children]); + + const updateAnnouncement = () => { + setAnnouncement( + `You have ${Math.floor(Math.random() * 1000)} new notifications.` + ); + }; + + const content = ( + <> +
+ + Update announcement + + + {announcement} + {announcement} +
+ + ); + + return ( + <> + setFlyoutOpen((open) => !open)}> + Toggle flyout + + + {isFlyoutOpen && ( + setFlyoutOpen(false)}> + {content} + + )} + + ); + }, }; diff --git a/packages/eui/src/components/accessibility/screen_reader_live/screen_reader_live.tsx b/packages/eui/src/components/accessibility/screen_reader_live/screen_reader_live.tsx index 6c4c8eee169..8b2cd86c679 100644 --- a/packages/eui/src/components/accessibility/screen_reader_live/screen_reader_live.tsx +++ b/packages/eui/src/components/accessibility/screen_reader_live/screen_reader_live.tsx @@ -31,10 +31,14 @@ export interface EuiScreenReaderLiveProps { * `role` attribute for both live regions. * * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions#roles_with_implicit_live_region_attributes + * + * @default 'status' */ role?: HTMLAttributes['role']; /** * `aria-live` attribute for both live regions + * + * @default 'polite' */ 'aria-live'?: AriaAttributes['aria-live']; /** @@ -42,6 +46,8 @@ export interface EuiScreenReaderLiveProps { * to automatically read out the text content. This prop should primarily be used for * navigation or page changes, where programmatically resetting focus location back to * a certain part of the page is desired. + * + * @default false */ focusRegionOnTextChange?: boolean; } @@ -86,6 +92,7 @@ export const EuiScreenReaderLive: FunctionComponent< aria-atomic="true" // Setting `aria-hidden` and setting `aria-live` to "off" prevents // double announcements from VO when `focusRegionOnTextChange` is true + // WARNING: This solves the issue in VO/Chrome but results in VO/Safari not reading anything at all aria-hidden={toggle ? undefined : 'true'} aria-live={!toggle || focusRegionOnTextChange ? 'off' : ariaLive} > diff --git a/packages/eui/src/components/call_out/call_out.stories.tsx b/packages/eui/src/components/call_out/call_out.stories.tsx index 7f40c86a655..3e4cbc4ea08 100644 --- a/packages/eui/src/components/call_out/call_out.stories.tsx +++ b/packages/eui/src/components/call_out/call_out.stories.tsx @@ -6,8 +6,10 @@ * Side Public License, v 1. */ +import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import { EuiButton } from '../button'; import { EuiCallOut, EuiCallOutProps } from './call_out'; const meta: Meta = { @@ -33,3 +35,33 @@ export const Playground: Story = { children: 'Callout text', }, }; + +export const AnnounceOnMount: Story = { + parameters: { + controls: { + include: ['children', 'announceOnMount'], + }, + loki: { + skip: true, + }, + }, + args: { + title: 'Callout title', + children: 'Callout text', + announceOnMount: true, + }, + render: function Render() { + const [isShown, setShown] = useState(false); + + return ( + <> + setShown(!isShown)}>Toggle CallOut + {isShown && ( + + Callout text + + )} + + ); + }, +}; diff --git a/packages/eui/src/components/call_out/call_out.test.tsx b/packages/eui/src/components/call_out/call_out.test.tsx index 50c992e2319..3325b20de0f 100644 --- a/packages/eui/src/components/call_out/call_out.test.tsx +++ b/packages/eui/src/components/call_out/call_out.test.tsx @@ -7,9 +7,9 @@ */ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { act, fireEvent } from '@testing-library/react'; import { requiredProps } from '../../test/required_props'; -import { render } from '../../test/rtl'; +import { render, screen } from '../../test/rtl'; import { EuiCallOut, COLORS, HEADINGS } from './call_out'; @@ -63,12 +63,60 @@ describe('EuiCallOut', () => { test('onDismiss', () => { const onDismiss = jest.fn(); - const { getByTestSubject } = render( - Content - ); + render(Content); - fireEvent.click(getByTestSubject('euiDismissCalloutButton')); + fireEvent.click(screen.getByTestSubject('euiDismissCalloutButton')); expect(onDismiss).toHaveBeenCalledTimes(1); }); + + describe('announceOnMount', () => { + jest.useFakeTimers(); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('announces the callout content in an aria-live region when announceOnMount is true', () => { + render( + + Announcement content + + ); + + act(() => { + jest.advanceTimersByTime(50); + }); + + // The live region should exist and contain the announcement + const liveRegion = screen.getByRole('status'); + expect(liveRegion).toHaveTextContent( + 'Announcement title, Announcement content' + ); + }); + + it('clears the announcement after 2000ms', () => { + render( + + Announcement content + + ); + + act(() => { + jest.advanceTimersByTime(50); + }); + + // The live region should exist and contain the announcement + const liveRegion = screen.getByRole('status'); + expect(liveRegion).toHaveTextContent( + 'Announcement title, Announcement content' + ); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(liveRegion).toHaveTextContent(''); + }); + }); }); }); diff --git a/packages/eui/src/components/call_out/call_out.tsx b/packages/eui/src/components/call_out/call_out.tsx index 5fb5c9a1806..ef5531912ba 100644 --- a/packages/eui/src/components/call_out/call_out.tsx +++ b/packages/eui/src/components/call_out/call_out.tsx @@ -19,6 +19,7 @@ import { EuiPanel } from '../panel'; import { EuiSpacer } from '../spacer'; import { EuiTitle } from '../title'; import { EuiI18n } from '../i18n'; +import { EuiLiveAnnouncer } from '../accessibility/live_announcer'; import { euiCallOutStyles, euiCallOutHeaderStyles } from './call_out.styles'; @@ -51,6 +52,13 @@ export type EuiCallOutProps = CommonProps & * removing the callout or other actions. */ onDismiss?: () => void; + /** + * Enables the content to be read by screen readers on mount. + * Use this for callouts that are shown based on a user action. + * + * @default false + */ + announceOnMount?: boolean; }; export const EuiCallOut = forwardRef( @@ -64,6 +72,7 @@ export const EuiCallOut = forwardRef( className, heading = 'p', onDismiss, + announceOnMount = false, ...rest }, ref @@ -170,6 +179,12 @@ export const EuiCallOut = forwardRef( ) } + {announceOnMount && (title || children) && ( + + {title && `${title}, `} + {children} + + )} ); } diff --git a/packages/website/docs/components/display/callout.mdx b/packages/website/docs/components/display/callout.mdx index 60f7413cb42..3f9ef85357f 100644 --- a/packages/website/docs/components/display/callout.mdx +++ b/packages/website/docs/components/display/callout.mdx @@ -241,6 +241,44 @@ export default () => { }; ``` +### Screen reader announcements + +`EuiCallOut` supports announcing its content to screen readers when it first appears using the `announceOnMount` prop. +When enabled, the callout's title and content will be announced via an `aria-live` region. The announcement will be cleared after 2 seconds +to prevent duplicate DOM content but screen readers will continue reading the message even after it's cleared. + +Generally you won't need this prop when a callout is rendered on initial page load, as screen readers will encounter the content during normal page navigation. +Use `announceOnMount` when the callout appears as the result of a user action or contains information that needs immediate attention. + + +```tsx interactive +import React, { useState } from 'react'; +import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; + +export default () => { + const [isShown, setIsShown] = useState(false); + + return ( + <> + setIsShown(!isShown)}> + {isShown ? 'Hide' : 'Show'} callout + + + {isShown && ( + +

This content will be announced to screen readers when it appears.

+
+ )} + + ); +}; +``` + + ## Guidelines **Keep these guidelines in mind:** diff --git a/packages/website/docs/utilities/accessibility/index.mdx b/packages/website/docs/utilities/accessibility/index.mdx index 9511dacb313..579cf26a6a6 100644 --- a/packages/website/docs/utilities/accessibility/index.mdx +++ b/packages/website/docs/utilities/accessibility/index.mdx @@ -190,6 +190,81 @@ This is primarily useful for announcing navigation or page changes, when program ``` +import { EuiBetaBadge } from '@elastic/eui'; + +## Live announcer region + +:::warning `EuiLiveAnnouncer` is a new, experimental alternative to `EuiScreenReaderLive` for screen reader live region announcements. +Its API and behavior may change based on feedback and testing. +::: + +Both `EuiLiveAnnouncer` and `EuiScreenReaderLive` are designed for the **same use cases**: to announce dynamic content, such as nodifications or status changes based on user interaction. +The main drawback for live regions is that they must be present on initial page load before reading content updates. `EuiLiveAnnouncer` addresses this +by updating the content after the initial render to ensure an announcement is made when mounting a component. + +Same as for `EuiScreenReaderLive`, `EuiLiveAnnouncer` supports configurable `role` and `aria-live` props which default to `status` and `polite` respectively for non-intrusive but timely update announcements. +If you're not using the default values, be sure to follow [ARIA guidelines](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) for `role` to `aria-live` mapping. + +### Main difference + +- **`EuiLiveAnnouncer`** will announce the message **when the component mounts** (e.g., when it first appears in the DOM), as well as when the message changes. +- **`EuiScreenReaderLive`** will only announce when the message changes, and will not announce the initial message on mount. + +This means `EuiLiveAnnouncer` is especially useful for scenarios where you want to ensure an announcement is made immediately when a component is mounted, e.g. showing a notification or alert based on user interaction. + +An additional difference is that `EuiLiveAnnouncer` can clear its message after a configurable timeout via `clearAfterMs`, which prevents stale +announcements and duplicated DOM content for screen reader users. Screen readers will continue reading the message even if it was already cleared. + + + ```tsx + import { useState } from 'react'; + import { EuiButton, EuiLiveAnnouncer, EuiSpacer } from '@elastic/eui'; + + export default () => { + const [announcement, setAnnouncement] = useState('No notifications.'); + const [isShown, setIsShown] = useState(false); + + return ( + + + setIsShown((shown) => !shown)}> + Toggle announcement + + + + + {isShown && ( + <> + setAnnouncement(`You have ${Math.floor(Math.random() * 1000)} new notifications.`)}> + Update announcement + + + {announcement} + + {announcement} + + + )} + + + ); + }; + ``` + + +### Key differences between `EuiLiveAnnouncer` and `EuiScreenReaderLive` + +| Feature | `EuiLiveAnnouncer` | `EuiScreenReaderLive` | +|--------------------------------|:---------------------------------:|:-------------------------------:| +| **Auto-clear message** | Yes, after configurable timeout (`clearAfterMs`) | No | +| **Announce on mount** | Yes | No | +| **Announce on message change** | Yes | Yes | +| **Focus on message change** | No | Yes (`focusRegionOnTextChange` prop) | +| **Handles rapid updates** | Yes | Yes | +| **Output consistency** | High | Medium | +| **API stability** | Beta/experimental | Stable | + + ## Skip link The `EuiSkipLink` component allows users to bypass navigation, or ornamental elements, and quickly reach the main content of the page. It requires a `destinationId` which should match the `id` of your main content. If your ID does not correspond to a valid element, the skip link will fall back to focusing the `
` tag on your page, if it exists. @@ -288,4 +363,5 @@ import docgen from '@elastic/eui-docgen/dist/components'; +