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. +

+ + ), + props: { + EuiScreenReaderLive, + }, + demo: , + }, { title: 'Skip link', source: [ diff --git a/src-docs/src/views/accessibility/screen_reader_live.tsx b/src-docs/src/views/accessibility/screen_reader_live.tsx new file mode 100644 index 00000000000..9ebd07c4aec --- /dev/null +++ b/src-docs/src/views/accessibility/screen_reader_live.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; + +import { + EuiCode, + EuiFieldNumber, + EuiFormRow, + EuiScreenReaderLive, + EuiSpacer, + EuiText, +} from '../../../../src/components'; + +export default () => { + const [value, setValue] = useState(1); + return ( + <> + + setValue(Number(e.target.value))} + min={0} + /> + + + +

+ 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('renders screen reader content when active', () => { + const component = render( + {content} + ); + + expect(component).toMatchSnapshot(); + }); + + it('does not render screen reader content when inactive', () => { + const component = render( + {content} + ); + + expect(component).toMatchSnapshot(); + }); + + it('accepts `role`', () => { + const component = render( + {content} + ); + + expect(component).toMatchSnapshot(); + }); + + it('accepts `aria-live`', () => { + const component = render( + + {content} + + ); + + expect(component).toMatchSnapshot(); + }); + }); + + describe('with dynamic properties', () => { + const Component = () => { + const [activeOptions, setActiveOptions] = useState(0); + + return ( +
+ + +

Number of active options: {activeOptions}

+
+
+ ); + }; + + 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