Skip to content

Commit

Permalink
Add autocomplete screen reader support (#48)
Browse files Browse the repository at this point in the history
Add a `ScreenReader` component that is used to provide screen reader instructions and announcements. Use this component in `InputDropdown` to give autocomplete instructions and screen reader announcements for number of autocomplete options found.

J=SLAP-1402
TEST=manual

Check that screen reader instructions and announcements are correct in the sample app. An announcement is made for each input change and on clicking the input bar if there is input present.
  • Loading branch information
nmanu1 committed Oct 29, 2021
1 parent 37050f2 commit 9c5c492
Show file tree
Hide file tree
Showing 6 changed files with 98 additions and 13 deletions.
45 changes: 37 additions & 8 deletions sample-app/src/components/InputDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { useReducer, KeyboardEvent, useRef, useEffect, useState } from "react"
import Dropdown, { Option } from './Dropdown';
import ScreenReader from "./ScreenReader";
import { processTranslation } from './utils/processTranslation';

interface Props {
inputValue?: string,
placeholder?: string,
screenReaderInstructions: string,
screenReaderInstructionsId: string,
options: Option[],
optionIdPrefix: string,
onSubmit?: (value: string) => void,
Expand All @@ -24,18 +28,18 @@ interface State {
shouldDisplayDropdown: boolean
}

type Action =
type Action =
| { type: 'HideOptions' }
| { type: 'ShowOptions' }
| { type: 'FocusOption', newIndex?: number }

function reducer(state: State, action: Action): State {
switch(action.type) {
case 'HideOptions':
switch (action.type) {
case 'HideOptions':
return { focusedOptionIndex: undefined, shouldDisplayDropdown: false }
case 'ShowOptions':
case 'ShowOptions':
return { focusedOptionIndex: undefined, shouldDisplayDropdown: true }
case 'FocusOption':
case 'FocusOption':
return { focusedOptionIndex: action.newIndex, shouldDisplayDropdown: true }
}
}
Expand All @@ -46,6 +50,8 @@ function reducer(state: State, action: Action): State {
export default function InputDropdown({
inputValue = '',
placeholder,
screenReaderInstructions,
screenReaderInstructionsId,
options,
optionIdPrefix,
onSubmit = () => {},
Expand All @@ -61,14 +67,19 @@ export default function InputDropdown({
focusedOptionIndex: undefined,
shouldDisplayDropdown: false,
});
const focusOptionId = focusedOptionIndex === undefined
const focusOptionId = focusedOptionIndex === undefined
? undefined
: `${optionIdPrefix}-${focusedOptionIndex}`;

const [latestUserInput, setLatestUserInput] = useState(inputValue);
const [screenReaderKey, setScreenReaderKey] = useState(0);

const inputRef = useRef<HTMLInputElement>(document.createElement('input'));

if (!shouldDisplayDropdown && screenReaderKey) {
setScreenReaderKey(0);
}

const inputRef = useRef<HTMLInputElement>(document.createElement('input'));

function handleDocumentClick(evt: MouseEvent) {
const target = evt.target as HTMLElement;
if (!target.isSameNode(inputRef.current)) {
Expand Down Expand Up @@ -121,18 +132,36 @@ export default function InputDropdown({
setLatestUserInput(value);
updateInputValue(value);
updateDropdown();
setScreenReaderKey(screenReaderKey + 1);
}}
onClick={() => {
dispatch({ type: 'ShowOptions' });
updateDropdown();
if (options.length || inputValue) {
setScreenReaderKey(screenReaderKey + 1);
}
}}
onKeyDown={onKeyDown}
value={inputValue}
ref={inputRef}
aria-describedby={screenReaderInstructionsId}
aria-activedescendant={focusOptionId}
/>
{renderButtons()}
</div>
<ScreenReader
instructionsId={screenReaderInstructionsId}
instructions={screenReaderInstructions}
announcementKey={screenReaderKey}
announcementText={screenReaderKey ?
processTranslation({
phrase: `${options.length} autocomplete option found.`,
pluralForm: `${options.length} autocomplete options found.`,
count: options.length
})
: ''
}
/>
{shouldDisplayDropdown &&
<Dropdown
options={options}
Expand Down
34 changes: 34 additions & 0 deletions sample-app/src/components/ScreenReader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import '../sass/ScreenReader.scss';

interface Props {
instructionsId: string,
instructions: string,
announcementKey: number,
announcementText: string
}

export default function ScreenReader({
instructionsId,
instructions,
announcementKey,
announcementText,
}: Props): JSX.Element | null {

return (
<>
<div
id={instructionsId}
className='ScreenReader__instructions'
>
{instructions}
</div>
<div
className='ScreenReader__announcementText'
key={announcementKey}
aria-live='assertive'
>
{announcementText}
</div>
</>
);
};
13 changes: 9 additions & 4 deletions sample-app/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import '../sass/SearchBar.scss';
import '../sass/Autocomplete.scss';
import LoadingIndicator from './LoadingIndicator';

const SCREENREADER_INSTRUCTIONS = 'When autocomplete results are available, use up and down arrows to review and enter to select.'

interface Props {
placeholder?: string,
isVertical: boolean
isVertical: boolean,
screenReaderInstructionsId: string
}

/**
* Renders a SearchBar that is hooked up with an Autocomplete component
*/
export default function SearchBar({ placeholder, isVertical }: Props) {
export default function SearchBar({ placeholder, isVertical, screenReaderInstructionsId }: Props) {
const answersActions = useAnswersActions();
const query = useAnswersState(state => state.query.query);
const mapStateToAutocompleteResults: StateSelector<AutocompleteResult[] | undefined> = isVertical
Expand All @@ -25,13 +28,13 @@ export default function SearchBar({ placeholder, isVertical }: Props) {
const isLoading = useAnswersState(state => state.vertical.searchLoading || state.universal.searchLoading);

function executeAutocomplete () {
isVertical
isVertical
? answersActions.executeVerticalAutoComplete()
: answersActions.executeUniversalAutoComplete()
}

function executeQuery () {
isVertical
isVertical
? answersActions.executeVerticalQuery()
: answersActions.executeUniversalQuery();
}
Expand All @@ -54,6 +57,8 @@ export default function SearchBar({ placeholder, isVertical }: Props) {
<InputDropdown
inputValue={query}
placeholder={placeholder}
screenReaderInstructions={SCREENREADER_INSTRUCTIONS}
screenReaderInstructionsId={screenReaderInstructionsId}
options={autocompleteResults.map(result => {
return {
value: result.value,
Expand Down
2 changes: 1 addition & 1 deletion sample-app/src/components/utils/processTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export function processTranslation(args: {
pluralForm?: string,
count?: number
}) {
if (args.count && args.pluralForm && args.count >= 2) {
if (args.count != null && args.pluralForm && args.count !== 1) {
return args.pluralForm
} else {
return args.phrase;
Expand Down
1 change: 1 addition & 0 deletions sample-app/src/pages/StandardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const StandardLayout: LayoutComponent = ({ page }) => {
<SearchBar
placeholder='Search...'
isVertical={isVertical}
screenReaderInstructionsId='SearchBar__srInstructions'
/>
<Navigation links={navLinks} />
{page}
Expand Down
16 changes: 16 additions & 0 deletions sample-app/src/sass/ScreenReader.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.ScreenReader {
&__instructions {
display: none;
}

&__announcementText {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
}

0 comments on commit 9c5c492

Please sign in to comment.