From 9c5c492d17844fa11b4293b695a3a1e0975f54a1 Mon Sep 17 00:00:00 2001 From: nmanu1 <88398086+nmanu1@users.noreply.github.com> Date: Fri, 29 Oct 2021 15:01:12 -0400 Subject: [PATCH] Add autocomplete screen reader support (#48) 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. --- sample-app/src/components/InputDropdown.tsx | 45 +++++++++++++++---- sample-app/src/components/ScreenReader.tsx | 34 ++++++++++++++ sample-app/src/components/SearchBar.tsx | 13 ++++-- .../components/utils/processTranslation.ts | 2 +- sample-app/src/pages/StandardLayout.tsx | 1 + sample-app/src/sass/ScreenReader.scss | 16 +++++++ 6 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 sample-app/src/components/ScreenReader.tsx create mode 100644 sample-app/src/sass/ScreenReader.scss diff --git a/sample-app/src/components/InputDropdown.tsx b/sample-app/src/components/InputDropdown.tsx index d3f14e5a..b2cdf81e 100644 --- a/sample-app/src/components/InputDropdown.tsx +++ b/sample-app/src/components/InputDropdown.tsx @@ -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, @@ -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 } } } @@ -46,6 +50,8 @@ function reducer(state: State, action: Action): State { export default function InputDropdown({ inputValue = '', placeholder, + screenReaderInstructions, + screenReaderInstructionsId, options, optionIdPrefix, onSubmit = () => {}, @@ -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(document.createElement('input')); + + if (!shouldDisplayDropdown && screenReaderKey) { + setScreenReaderKey(0); + } - const inputRef = useRef(document.createElement('input')); - function handleDocumentClick(evt: MouseEvent) { const target = evt.target as HTMLElement; if (!target.isSameNode(inputRef.current)) { @@ -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()} + {shouldDisplayDropdown && +
+ {instructions} +
+
+ {announcementText} +
+ + ); +}; diff --git a/sample-app/src/components/SearchBar.tsx b/sample-app/src/components/SearchBar.tsx index 9000b8b7..20942ed9 100644 --- a/sample-app/src/components/SearchBar.tsx +++ b/sample-app/src/components/SearchBar.tsx @@ -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 = isVertical @@ -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(); } @@ -54,6 +57,8 @@ export default function SearchBar({ placeholder, isVertical }: Props) { { return { value: result.value, diff --git a/sample-app/src/components/utils/processTranslation.ts b/sample-app/src/components/utils/processTranslation.ts index 63f9fa1e..f2d1681c 100644 --- a/sample-app/src/components/utils/processTranslation.ts +++ b/sample-app/src/components/utils/processTranslation.ts @@ -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; diff --git a/sample-app/src/pages/StandardLayout.tsx b/sample-app/src/pages/StandardLayout.tsx index d8b8f242..ff40b93c 100644 --- a/sample-app/src/pages/StandardLayout.tsx +++ b/sample-app/src/pages/StandardLayout.tsx @@ -25,6 +25,7 @@ const StandardLayout: LayoutComponent = ({ page }) => { {page} diff --git a/sample-app/src/sass/ScreenReader.scss b/sample-app/src/sass/ScreenReader.scss new file mode 100644 index 00000000..9597ce75 --- /dev/null +++ b/sample-app/src/sass/ScreenReader.scss @@ -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; + } +}