diff --git a/CHANGELOG.md b/CHANGELOG.md
index f17881e4752..d98034d67c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
- Added referenceable `id` for the generated label in `EuiFormRow` ([#5574](https://github.com/elastic/eui/pull/5574))
- Addeed optional attribute configurations in `EuiPopover` to aid screen reader announcements ([#5574](https://github.com/elastic/eui/pull/5574))
- Added `ref` passthroughs to `EuiIputPopover` subcomponents ([#5574](https://github.com/elastic/eui/pull/5574))
+- Added `EuiScreenReaderLive` component for updateable `aria-live` regions ([#5567](https://github.com/elastic/eui/pull/5567))
**Bug fixes**
diff --git a/src-docs/src/views/accessibility/accessibility_example.js b/src-docs/src/views/accessibility/accessibility_example.js
index d1d7ea6d6cd..dc55c413abe 100644
--- a/src-docs/src/views/accessibility/accessibility_example.js
+++ b/src-docs/src/views/accessibility/accessibility_example.js
@@ -7,14 +7,17 @@ import {
EuiCode,
EuiLink,
EuiSkipLink,
+ EuiScreenReaderLive,
EuiScreenReaderOnly,
EuiSpacer,
} from '../../../../src/components';
+import ScreenReaderLive from './screen_reader_live';
import ScreenReaderOnly from './screen_reader';
import ScreenReaderFocus from './screen_reader_focus';
import SkipLink from './skip_link';
+const screenReaderLiveSource = require('!!raw-loader!./screen_reader_live');
const screenReaderOnlySource = require('!!raw-loader!./screen_reader');
const screenReaderFocusSource = require('!!raw-loader!./screen_reader_focus');
@@ -112,6 +115,46 @@ export const AccessibilityExample = {
`,
demo: ,
},
+ {
+ title: 'Screen reader live region',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: screenReaderLiveSource,
+ },
+ ],
+ text: (
+ <>
+
+ Using EuiScreenReaderLive to announce dynamic
+ content, such as status changes based on user interaction.
+
+
+ The configurable role and{' '}
+ aria-live props default to{' '}
+ status and polite respectively
+ for unintrusive but timely update announcements. When not using the
+ default values, be sure to follow{' '}
+
+ ARIA guidelines
+ {' '}
+ for role to aria-live mapping.
+
+
+ Also consider other live region guidelines, such as that live
+ regions must be present on initial page load, and should not be in a
+ conditional JSX wrapper.
+
+ Content announced by screen reader:
+ Current value: {value}
+
+
+
Current value: {value}
+
+
+ >
+ );
+};
diff --git a/src/components/accessibility/index.ts b/src/components/accessibility/index.ts
index 2807d54183f..b7fb2cfa1ee 100644
--- a/src/components/accessibility/index.ts
+++ b/src/components/accessibility/index.ts
@@ -6,5 +6,12 @@
* Side Public License, v 1.
*/
-export { EuiScreenReaderOnly } from './screen_reader';
+export {
+ EuiScreenReaderLive,
+ EuiScreenReaderLiveProps,
+} from './screen_reader_live';
+export {
+ EuiScreenReaderOnly,
+ EuiScreenReaderOnlyProps,
+} from './screen_reader_only';
export { EuiSkipLink, EuiSkipLinkProps } from './skip_link';
diff --git a/src/components/accessibility/screen_reader_live/__snapshots__/screen_reader_live.test.tsx.snap b/src/components/accessibility/screen_reader_live/__snapshots__/screen_reader_live.test.tsx.snap
new file mode 100644
index 00000000000..41dd2c3fbcd
--- /dev/null
+++ b/src/components/accessibility/screen_reader_live/__snapshots__/screen_reader_live.test.tsx.snap
@@ -0,0 +1,153 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EuiScreenReaderLive with a static configuration accepts \`aria-live\` 1`] = `
+
+
+
+
+ This paragraph is not visible to sighted users but will be read by screenreaders.
+
+
+
+`;
+
+exports[`EuiScreenReaderLive with a static configuration accepts \`role\` 1`] = `
+
+
+
+
+ This paragraph is not visible to sighted users but will be read by screenreaders.
+
+
+
+`;
+
+exports[`EuiScreenReaderLive with a static configuration does not render screen reader content when inactive 1`] = `
+
+
+
+
+`;
+
+exports[`EuiScreenReaderLive with a static configuration renders screen reader content when active 1`] = `
+
+
+
+
+ This paragraph is not visible to sighted users but will be read by screenreaders.
+
+
+
+`;
+
+exports[`EuiScreenReaderLive with dynamic properties alternates rendering screen reader content into the second live region when changed/toggled 1`] = `
+
+
+
+
+
+
+
+
+
+ Number of active options:
+ 1
+
+
+
+
+
+
+
+`;
+
+exports[`EuiScreenReaderLive with dynamic properties initially renders screen reader content in the first live region 1`] = `
+
+
+
+
+
+
+
+
+ Number of active options:
+ 0
+
+
+
+
+
+
+
+
+`;
diff --git a/src/components/accessibility/screen_reader_live/index.ts b/src/components/accessibility/screen_reader_live/index.ts
new file mode 100644
index 00000000000..c21251edfce
--- /dev/null
+++ b/src/components/accessibility/screen_reader_live/index.ts
@@ -0,0 +1,12 @@
+/*
+ * 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 {
+ EuiScreenReaderLive,
+ EuiScreenReaderLiveProps,
+} from './screen_reader_live';
diff --git a/src/components/accessibility/screen_reader_live/screen_reader_live.test.tsx b/src/components/accessibility/screen_reader_live/screen_reader_live.test.tsx
new file mode 100644
index 00000000000..944745b1850
--- /dev/null
+++ b/src/components/accessibility/screen_reader_live/screen_reader_live.test.tsx
@@ -0,0 +1,93 @@
+/*
+ * 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, { useState } from 'react';
+import { mount, render } from 'enzyme';
+
+import { findTestSubject } from '../../../test';
+
+import { EuiScreenReaderLive } from './screen_reader_live';
+
+describe('EuiScreenReaderLive', () => {
+ describe('with a static configuration', () => {
+ const content = (
+
+ This paragraph is not visible to sighted users but will be read by
+ screenreaders.
+
+ );
+ };
+
+ it('initially renders screen reader content in the first live region', () => {
+ const component = mount();
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('alternates rendering screen reader content into the second live region when changed/toggled', () => {
+ const component = mount();
+
+ findTestSubject(component, 'increment').simulate('click');
+
+ expect(component).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/components/accessibility/screen_reader_live/screen_reader_live.tsx b/src/components/accessibility/screen_reader_live/screen_reader_live.tsx
new file mode 100644
index 00000000000..88d4a256497
--- /dev/null
+++ b/src/components/accessibility/screen_reader_live/screen_reader_live.tsx
@@ -0,0 +1,75 @@
+/*
+ * 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, {
+ AriaAttributes,
+ HTMLAttributes,
+ FunctionComponent,
+ ReactNode,
+ useEffect,
+ useState,
+} from 'react';
+
+import { EuiScreenReaderOnly } from '../screen_reader_only';
+
+export interface EuiScreenReaderLiveProps {
+ /**
+ * Whether to make screen readers aware of the content
+ */
+ isActive?: boolean;
+ /**
+ * Content for screen readers to announce
+ */
+ children?: ReactNode;
+ /**
+ * `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
+ */
+ role?: HTMLAttributes['role'];
+ /**
+ * `aria-live` attribute for both live regions
+ */
+ 'aria-live'?: AriaAttributes['aria-live'];
+}
+
+export const EuiScreenReaderLive: FunctionComponent = ({
+ children,
+ isActive = true,
+ role = 'status',
+ 'aria-live': ariaLive = 'polite',
+}) => {
+ const [toggle, setToggle] = useState(false);
+
+ useEffect(() => {
+ setToggle((toggle) => !toggle);
+ }, [children]);
+
+ return (
+ /**
+ * Intentionally uses two persistent live regions with oscillating content updates.
+ * This resolves the problem of duplicate screen reader announcements in rapid succession
+ * caused by React's virtual DOM behaviour (https://github.com/nvaccess/nvda/issues/7996#issuecomment-413641709)
+ *
+ * Adapted from https://github.com/alphagov/accessible-autocomplete/blob/a7106f03150941fc15e6c1ceb0a90e8872fa86ef/src/status.js
+ * Debouncing was not needed for this case, but could prove to be useful for future use cases.
+ * See also https://github.com/AlmeroSteyn/react-aria-live and https://github.com/dequelabs/ngA11y
+ * for more examples of the double region approach.
+ */
+
+
+
+ {isActive && toggle ? children : ''}
+
+
+ {isActive && !toggle ? children : ''}
+
+
+
+ );
+};
diff --git a/src/components/accessibility/__snapshots__/screen_reader.test.tsx.snap b/src/components/accessibility/screen_reader_only/__snapshots__/screen_reader_only.test.tsx.snap
similarity index 100%
rename from src/components/accessibility/__snapshots__/screen_reader.test.tsx.snap
rename to src/components/accessibility/screen_reader_only/__snapshots__/screen_reader_only.test.tsx.snap
diff --git a/src/components/accessibility/screen_reader_only/index.ts b/src/components/accessibility/screen_reader_only/index.ts
new file mode 100644
index 00000000000..5a8e9c04f1d
--- /dev/null
+++ b/src/components/accessibility/screen_reader_only/index.ts
@@ -0,0 +1,12 @@
+/*
+ * 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 {
+ EuiScreenReaderOnly,
+ EuiScreenReaderOnlyProps,
+} from './screen_reader_only';
diff --git a/src/components/accessibility/screen_reader.test.tsx b/src/components/accessibility/screen_reader_only/screen_reader_only.test.tsx
similarity index 96%
rename from src/components/accessibility/screen_reader.test.tsx
rename to src/components/accessibility/screen_reader_only/screen_reader_only.test.tsx
index eb44d5b0e12..5adeb8dad54 100644
--- a/src/components/accessibility/screen_reader.test.tsx
+++ b/src/components/accessibility/screen_reader_only/screen_reader_only.test.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { render } from 'enzyme';
-import { EuiScreenReaderOnly } from './screen_reader';
+import { EuiScreenReaderOnly } from './screen_reader_only';
describe('EuiScreenReaderOnly', () => {
describe('adds an accessibility class to a child element', () => {
diff --git a/src/components/accessibility/screen_reader.tsx b/src/components/accessibility/screen_reader_only/screen_reader_only.tsx
similarity index 100%
rename from src/components/accessibility/screen_reader.tsx
rename to src/components/accessibility/screen_reader_only/screen_reader_only.tsx
diff --git a/src/components/accessibility/__snapshots__/skip_link.test.tsx.snap b/src/components/accessibility/skip_link/__snapshots__/skip_link.test.tsx.snap
similarity index 100%
rename from src/components/accessibility/__snapshots__/skip_link.test.tsx.snap
rename to src/components/accessibility/skip_link/__snapshots__/skip_link.test.tsx.snap
diff --git a/src/components/accessibility/skip_link/index.ts b/src/components/accessibility/skip_link/index.ts
new file mode 100644
index 00000000000..ecd21b7b215
--- /dev/null
+++ b/src/components/accessibility/skip_link/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 { EuiSkipLink, EuiSkipLinkProps } from './skip_link';
diff --git a/src/components/accessibility/skip_link.test.tsx b/src/components/accessibility/skip_link/skip_link.test.tsx
similarity index 96%
rename from src/components/accessibility/skip_link.test.tsx
rename to src/components/accessibility/skip_link/skip_link.test.tsx
index bb7db30b50b..e81236bfa8f 100644
--- a/src/components/accessibility/skip_link.test.tsx
+++ b/src/components/accessibility/skip_link/skip_link.test.tsx
@@ -8,7 +8,7 @@
import React from 'react';
import { render } from 'enzyme';
-import { requiredProps } from '../../test';
+import { requiredProps } from '../../../test';
import { EuiSkipLink, POSITIONS } from './skip_link';
diff --git a/src/components/accessibility/skip_link.tsx b/src/components/accessibility/skip_link/skip_link.tsx
similarity index 93%
rename from src/components/accessibility/skip_link.tsx
rename to src/components/accessibility/skip_link/skip_link.tsx
index 034729c05ae..907c9769204 100644
--- a/src/components/accessibility/skip_link.tsx
+++ b/src/components/accessibility/skip_link/skip_link.tsx
@@ -8,9 +8,9 @@
import React, { FunctionComponent, Ref } from 'react';
import classNames from 'classnames';
-import { EuiButton, EuiButtonProps } from '../button/button';
-import { EuiScreenReaderOnly } from '../accessibility/screen_reader';
-import { PropsForAnchor, PropsForButton, ExclusiveUnion } from '../common';
+import { EuiButton, EuiButtonProps } from '../../button/button';
+import { EuiScreenReaderOnly } from '../screen_reader_only';
+import { PropsForAnchor, PropsForButton, ExclusiveUnion } from '../../common';
type Positions = 'static' | 'fixed' | 'absolute';
export const POSITIONS = ['static', 'fixed', 'absolute'] as Positions[];
diff --git a/src/components/color_picker/color_palette_display/color_palette_display_fixed.tsx b/src/components/color_picker/color_palette_display/color_palette_display_fixed.tsx
index eae20ec5969..ecfceee1006 100644
--- a/src/components/color_picker/color_palette_display/color_palette_display_fixed.tsx
+++ b/src/components/color_picker/color_palette_display/color_palette_display_fixed.tsx
@@ -9,7 +9,7 @@
import React, { FunctionComponent, HTMLAttributes } from 'react';
import { CommonProps } from '../../common';
import { getFixedLinearGradient } from '../utils';
-import { EuiScreenReaderOnly } from '../../accessibility/screen_reader';
+import { EuiScreenReaderOnly } from '../../accessibility';
import { EuiColorPaletteDisplayShared } from './color_palette_display';
export interface EuiColorPaletteDisplayFixedProps
diff --git a/src/components/color_picker/color_palette_display/color_palette_display_gradient.tsx b/src/components/color_picker/color_palette_display/color_palette_display_gradient.tsx
index bcc0b24935e..a46231c8777 100644
--- a/src/components/color_picker/color_palette_display/color_palette_display_gradient.tsx
+++ b/src/components/color_picker/color_palette_display/color_palette_display_gradient.tsx
@@ -9,7 +9,7 @@
import React, { FunctionComponent, HTMLAttributes } from 'react';
import { CommonProps } from '../../common';
import { getLinearGradient } from '../utils';
-import { EuiScreenReaderOnly } from '../../accessibility/screen_reader';
+import { EuiScreenReaderOnly } from '../../accessibility';
import { EuiColorPaletteDisplayShared } from './color_palette_display';
export interface EuiColorPaletteDisplayGradientProps