Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src-docs/src/components/guide_page/guide_page_chrome.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
EuiSideNav,
EuiPageSideBar,
EuiText,
EuiScreenReaderOnly,
} from '../../../../src/components';

import { EuiHighlight } from '../../../../src/components/highlight';
Expand Down Expand Up @@ -127,7 +128,16 @@ export class GuidePageChrome extends Component {

return {
id: sectionHref,
name: isCurrentlyOpenSubSection ? <strong>{name}</strong> : name,
name: isCurrentlyOpenSubSection ? (
<strong>{name}</strong>
) : (
<>
{name}
<EuiScreenReaderOnly>
<span> - same page</span>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this SR-only span to cue that some links in the docs left nav won't load a new route or announce themselves past the "click" sound.

</EuiScreenReaderOnly>
</>
),
href: sectionHref,
className: isCurrentlyOpenSubSection
? 'guideSideNav__item--openSubTitle'
Expand Down
65 changes: 64 additions & 1 deletion src-docs/src/views/accessibility/accessibility_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,26 @@ import {
EuiSkipLink,
EuiScreenReaderLive,
EuiScreenReaderOnly,
EuiScreenReaderStatus,
EuiSpacer,
} from '../../../../src';

import ScreenReaderLive from './screen_reader_live';
import ScreenReaderOnly from './screen_reader';
import ScreenReaderFocus from './screen_reader_focus';
import ScreenReaderStatus from './screen_reader_status';
import SkipLink from './skip_link';
import StylesHelpers from './styles_helpers';

const screenReaderLiveSource = require('!!raw-loader!./screen_reader_live');
const screenReaderOnlySource = require('!!raw-loader!./screen_reader');

const screenReaderFocusSource = require('!!raw-loader!./screen_reader_focus');
const screenReaderStatusSource = require('!!raw-loader!./screen_reader_status');
const screenReaderStatusSnippet = [
'<EuiScreenReaderStatus statusMessage="User-defined message" />',
'<EuiScreenReaderStatus statusMessage="User-defined message" shouldReceiveFocus />',
];

const skipLinkSource = require('!!raw-loader!./skip_link');
const skipLinkSnippet = [
Expand Down Expand Up @@ -127,7 +135,7 @@ export const AccessibilityExample = {
text: (
<>
<p>
Using <EuiCode>EuiScreenReaderLive</EuiCode> to announce dynamic
Use <EuiCode>EuiScreenReaderLive</EuiCode> to announce dynamic
content, such as status changes based on user interaction.
</p>
<p>
Expand Down Expand Up @@ -156,6 +164,61 @@ export const AccessibilityExample = {
},
demo: <ScreenReaderLive />,
},
{
title: 'Screen reader status',
source: [
{
type: GuideSectionTypes.JS,
code: screenReaderStatusSource,
},
],
text: (
<>
<p>
Use <EuiCode>EuiScreenReaderStatus</EuiCode> to announce status
changes such as content being loaded or client-side route changes.
</p>
<p>
The configurable <EuiCode>statusMessage</EuiCode> and{' '}
<EuiCode>shouldReceiveFocus</EuiCode> props default to{' '}
<EuiCode>document.title</EuiCode> and <EuiCode>false</EuiCode>{' '}
respectively.
</p>
<p>
Most users will want to pass a string to{' '}
<EuiCode>statusMessage</EuiCode>. This will add a non-focusable
status block to the page. This status will be read by screen readers
when there is a natural pause.
</p>
<p>
Passing <EuiCode>shouldReceiveFocus</EuiCode> sets keyboard focus on
the status block when
<EuiCode>EuiScreenReaderStatus</EuiCode> mounts, or the{' '}
<EuiCode>statusMessage</EuiCode> prop updates. Passing{' '}
<EuiCode>shouldReceiveFocus</EuiCode> also removes the live region.
This is useful for announcing client-side route changes to screen
readers and should <strong>only</strong> be used when the status
block is at the top of the HTML source order.
</p>
<p>
To learn more about the <EuiCode>status</EuiCode> role make sure to
read the{' '}
<EuiLink
href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role"
external
>
ARIA guidelines
</EuiLink>
.
</p>
</>
),
props: {
EuiScreenReaderStatus,
},
snippet: screenReaderStatusSnippet,
demo: <ScreenReaderStatus />,
},
{
title: 'Skip link',
source: [
Expand Down
14 changes: 14 additions & 0 deletions src-docs/src/views/accessibility/screen_reader_status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from 'react';

import { EuiScreenReaderStatus, EuiText } from '../../../../src/components';

export default () => (
<EuiText>
<p>This is the first paragraph. It is visible to all.</p>
<EuiScreenReaderStatus statusMessage="User-defined status message" />
<p>
This is the third paragraph. If you turn on a screen reader, there is a
status message between the paragraphs.
</p>
</EuiText>
);
5 changes: 5 additions & 0 deletions src-docs/src/views/app_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
EuiPageBody,
EuiSkipLink,
} from '../../../src/components';
import { EuiScreenReaderStatus } from '../../../src/components/accessibility/screen_reader_status';

import { keys } from '../../../src/services';

Expand Down Expand Up @@ -69,6 +70,10 @@ export const AppView = ({ children, currentRoute }) => {

return (
<LinkWrapper>
<EuiScreenReaderStatus
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested in Safari and Firefox with VoiceOver (MacOS Monterey). Request is out for more screen reader testing, but I have 💯 confidence this technique will work.

statusMessage={`${currentRoute.name} - Elastic UI Framework`}
shouldReceiveFocus
/>
<EuiSkipLink
destinationId="start-of-content"
position="fixed"
Expand Down
2 changes: 2 additions & 0 deletions src/components/accessibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ 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 { EuiScreenReaderStatus } from './screen_reader_status';
export type { EuiScreenReaderStatusProps } from './screen_reader_status';
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`EuiScreenReaderStatus it renders 1`] = `
<div
aria-live="polite"
class="euiScreenReaderOnly"
role="status"
/>
`;

exports[`EuiScreenReaderStatus it sets \`statusMessage\` using document title 1`] = `
<EuiScreenReaderStatus>
<EuiScreenReaderOnly>
<div
aria-live="polite"
className="euiScreenReaderOnly"
role="status"
>
Default title
</div>
</EuiScreenReaderOnly>
</EuiScreenReaderStatus>
`;

exports[`EuiScreenReaderStatus it sets \`statusMessage\` with user defined string 1`] = `
<EuiScreenReaderStatus
statusMessage="User-defined title"
>
<EuiScreenReaderOnly>
<div
aria-live="polite"
className="euiScreenReaderOnly"
role="status"
>
User-defined title
</div>
</EuiScreenReaderOnly>
</EuiScreenReaderStatus>
`;

exports[`EuiScreenReaderStatus it sets aria-live and tabindex correctly when \`shouldReceiveFocus\` is passed 1`] = `
<EuiScreenReaderStatus
shouldReceiveFocus={true}
statusMessage="User-defined title"
>
<EuiScreenReaderOnly>
<div
aria-live="off"
className="euiScreenReaderOnly"
role="status"
tabIndex={-1}
>
User-defined title
</div>
</EuiScreenReaderOnly>
</EuiScreenReaderStatus>
`;
10 changes: 10 additions & 0 deletions src/components/accessibility/screen_reader_status/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* 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 type { EuiScreenReaderStatusProps } from './screen_reader_status';
export { EuiScreenReaderStatus } from './screen_reader_status';
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* 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 from 'react';
import { mount, render } from 'enzyme';

import { EuiScreenReaderStatus } from './screen_reader_status';

describe('EuiScreenReaderStatus', () => {
test('it renders', () => {
const component = render(<EuiScreenReaderStatus />);
expect(component).toMatchSnapshot();
});

test('it sets `statusMessage` using document title', () => {
document.title = 'Default title';
const component = mount(<EuiScreenReaderStatus />);
expect(component).toMatchSnapshot();
});

test('it sets `statusMessage` with user defined string', () => {
const component = mount(
<EuiScreenReaderStatus statusMessage="User-defined title" />
);
expect(component).toMatchSnapshot();
});

test('it sets aria-live and tabindex correctly when `shouldReceiveFocus` is passed', () => {
const component = mount(
<EuiScreenReaderStatus
statusMessage="User-defined title"
shouldReceiveFocus
/>
);
expect(component).toMatchSnapshot();
});

test('it creates a non-focusable live region by default', () => {
const component = mount(
<EuiScreenReaderStatus statusMessage="User-defined title" />
);
const statusDiv = component.find('div');
expect(statusDiv.prop('tabindex')).toBe(undefined);
expect(statusDiv.prop('aria-live')).toBe('polite');
expect(statusDiv.text()).toBe('User-defined title');
});

test('it creates a focusable region when `shouldReceiveFocus` is passed', () => {
const component = mount(
<EuiScreenReaderStatus
statusMessage="User-defined title"
shouldReceiveFocus
/>
);
const statusDiv = component.find('div');
expect(statusDiv.prop('tabIndex')).toBe(-1);
expect(statusDiv.prop('aria-live')).toBe('off');
expect(statusDiv.text()).toBe('User-defined title');
});

test('it does not set focus by default', () => {
const component = mount(
<EuiScreenReaderStatus statusMessage="User-defined title" />
);
const statusDiv = component.find('div');
expect(statusDiv.is(':focus')).toBe(false);
});

test('it sets focus when `shouldReceiveFocus` is passed', () => {
const component = mount(
<EuiScreenReaderStatus
statusMessage="User-defined title"
shouldReceiveFocus
/>
);
const statusDiv = component.find('div');
expect(statusDiv.is(':focus')).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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, useEffect, useRef, useState } from 'react';
import { EuiScreenReaderOnly } from '../screen_reader_only';

export interface EuiScreenReaderStatusProps {
/**
* Set a custom status message to be announced to screen readers.
* Defaults to `document.title` and will announce client-side route change.
*/
statusMessage?: string;
/**
* Focuses the status message and removes the live region when set to true.
*/
shouldReceiveFocus?: boolean;
}

export const EuiScreenReaderStatus: FunctionComponent<EuiScreenReaderStatusProps> = ({
statusMessage = document.title,
shouldReceiveFocus = false,
}) => {
const [statusMessageState, setStatusMessageState] = useState('');
const statusRef = useRef<HTMLDivElement>(null);

useEffect(() => {
setStatusMessageState(statusMessage);

if (statusRef.current !== null && shouldReceiveFocus) {
statusRef.current.focus();
}

return () => {
setStatusMessageState('');
};
}, [statusMessage, shouldReceiveFocus]);

return (
<EuiScreenReaderOnly>
<div
aria-live={shouldReceiveFocus ? 'off' : 'polite'}
ref={statusRef}
role="status"
tabIndex={shouldReceiveFocus ? -1 : undefined}
>
{statusMessageState}
</div>
</EuiScreenReaderOnly>
);
};
Loading