Skip to content

Commit

Permalink
Merge pull request #355 from markhunter27/markh-final-pr
Browse files Browse the repository at this point in the history
Refinements to address accessibility issues
  • Loading branch information
NickColley committed Sep 19, 2019
2 parents baee858 + a5e724a commit 39c6176
Show file tree
Hide file tree
Showing 14 changed files with 854 additions and 63 deletions.
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,19 +278,19 @@ Type: `Function`

A function that receives no arguments and should return the text used in the dropdown to indicate that there are no results.

#### `tStatusQueryTooShort` (default: `` (minQueryLength) => `Type in ${minQueryLength} or more characters for results.` ``)
#### `tStatusQueryTooShort` (default: `` (minQueryLength) => `Type in ${minQueryLength} or more characters for results` ``)

Type: `Function`

A function that receives one argument that indicates the minimal amount of characters needed for the dropdown to trigger and should return the text used in the accessibility hint to indicate that the query is too short.

#### `tStatusNoResults` (default: `() => 'No search results.'`)
#### `tStatusNoResults` (default: `() => 'No search results'`)

Type: `Function`

A function that receives no arguments and should return the text that is used in the accessibility hint to indicate that there are no results.

#### `tStatusSelectedOption` (default: `` (selectedOption, length, index) => `${selectedOption} (${index + 1} of ${length}) is selected.` ``)
#### `tStatusSelectedOption` (default: `` (selectedOption, length, index) => `${selectedOption} ${index + 1} of ${length} is highlighted` ``)

Type: `Function`

Expand All @@ -315,6 +315,14 @@ Type: `Function`

A function that receives two arguments, the count of available options and the return value of `tStatusSelectedOption`, and should return the text used in the accessibility hint to indicate which options are available and which is selected.

#### `tAssistiveHint` (default: `() => 'When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.'`)

Type: `Function`

A function that receives no arguments and should return the text to be assigned as the aria description of the html `input` element, via the `aria-describedby` attribute.
This text is intended as an initial instruction to the assistive tech user. The `aria-describedby` attribute is automatically removed once user input is detected, in order to reduce screen reader verbosity.


## Progressive enhancement

If your autocomplete is meant to select from a small list of options (a few hundred), we strongly suggest that you render a `<select>` menu on the server, and use progressive enhancement.
Expand Down
2 changes: 1 addition & 1 deletion dist/accessible-autocomplete.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/accessible-autocomplete.min.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/lib/accessible-autocomplete.preact.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/lib/accessible-autocomplete.preact.min.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/lib/accessible-autocomplete.react.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/lib/accessible-autocomplete.react.min.js.map

Large diffs are not rendered by default.

379 changes: 379 additions & 0 deletions examples/form-single.html

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions examples/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
<main>
<h1>Accessible Autocomplete form example</h1>

<p><a href="form-single.html">Another HTML form example, with a single form field</a></p>

<div class="submitted submitted--hidden">
<p>You submitted:</p>
<ul>
Expand Down
63 changes: 51 additions & 12 deletions src/autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default class Autocomplete extends Component {
showAllValues: false,
required: false,
tNoResults: () => 'No results found',
tAssistiveHint: () => 'When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.',
dropdownArrow: DropdownArrowDown
}

Expand All @@ -71,7 +72,9 @@ export default class Autocomplete extends Component {
menuOpen: false,
options: props.defaultValue ? [props.defaultValue] : [],
query: props.defaultValue,
selected: null
validChoiceMade: false,
selected: null,
ariaHint: true
}

this.handleComponentBlur = this.handleComponentBlur.bind(this)
Expand All @@ -96,6 +99,10 @@ export default class Autocomplete extends Component {
this.getDirectInputChanges = this.getDirectInputChanges.bind(this)
}

isQueryAnOption (query, options) {
return options.map(entry => this.templateInputValue(entry).toLowerCase()).indexOf(query.toLowerCase()) !== -1
}

componentDidMount () {
this.pollInputElement()
}
Expand Down Expand Up @@ -172,7 +179,8 @@ export default class Autocomplete extends Component {
clicked: null,
menuOpen: newState.menuOpen || false,
query: newQuery,
selected: null
selected: null,
validChoiceMade: this.isQueryAnOption(newQuery, options)
})
}

Expand Down Expand Up @@ -219,7 +227,10 @@ export default class Autocomplete extends Component {
const queryChanged = this.state.query.length !== query.length
const queryLongEnough = query.length >= minLength

this.setState({ query })
this.setState({
query,
ariaHint: queryEmpty
})

const searchForOptions = showAllValues || (!queryEmpty && queryChanged && queryLongEnough)
if (searchForOptions) {
Expand All @@ -228,7 +239,8 @@ export default class Autocomplete extends Component {
this.setState({
menuOpen: optionsAvailable,
options,
selected: (autoselect && optionsAvailable) ? 0 : -1
selected: (autoselect && optionsAvailable) ? 0 : -1,
validChoiceMade: false
})
})
} else if (queryEmpty || !queryLongEnough) {
Expand All @@ -244,9 +256,15 @@ export default class Autocomplete extends Component {
}

handleInputFocus (event) {
this.setState({
focused: -1
})
const { query, validChoiceMade, options } = this.state
const { minLength } = this.props
const shouldReopenMenu = !validChoiceMade && query.length >= minLength && options.length > 0

if (shouldReopenMenu) {
this.setState(({ menuOpen }) => ({ focused: -1, menuOpen: shouldReopenMenu || menuOpen, selected: -1 }))
} else {
this.setState({ focused: -1 })
}
}

handleOptionFocus (index) {
Expand Down Expand Up @@ -278,7 +296,8 @@ export default class Autocomplete extends Component {
hovered: null,
menuOpen: false,
query: newQuery,
selected: -1
selected: -1,
validChoiceMade: true
})
this.forceUpdate()
}
Expand Down Expand Up @@ -398,9 +417,10 @@ export default class Autocomplete extends Component {
tStatusNoResults,
tStatusSelectedOption,
tStatusResults,
tAssistiveHint,
dropdownArrow: dropdownArrowFactory
} = this.props
const { focused, hovered, menuOpen, options, query, selected } = this.state
const { focused, hovered, menuOpen, options, query, selected, ariaHint, validChoiceMade } = this.state
const autoselect = this.hasAutoselect()

const inputFocused = focused === -1
Expand Down Expand Up @@ -435,6 +455,10 @@ export default class Autocomplete extends Component {
: ''
const showHint = hasPointerEvents && hintValue

const ariaDescribedProp = (ariaHint) ? {
'aria-describedby': 'assistiveHint'
} : null

let dropdownArrow

// we only need a dropdown arrow if showAllValues is set to a truthy value
Expand All @@ -448,13 +472,15 @@ export default class Autocomplete extends Component {
}

return (
<div className={wrapperClassName} onKeyDown={this.handleKeyDown} role='combobox' aria-expanded={menuOpen ? 'true' : 'false'}>
<div className={wrapperClassName} onKeyDown={this.handleKeyDown}>
<Status
length={options.length}
queryLength={query.length}
minQueryLength={minLength}
selectedOption={this.templateInputValue(options[selected])}
selectedOptionIndex={selected}
validChoiceMade={validChoiceMade}
isInFocus={this.state.focused !== null}
tQueryTooShort={tStatusQueryTooShort}
tNoResults={tStatusNoResults}
tSelectedOption={tStatusSelectedOption}
Expand All @@ -466,8 +492,11 @@ export default class Autocomplete extends Component {
)}

<input
aria-expanded={menuOpen ? 'true' : 'false'}
aria-activedescendant={optionFocused ? `${id}__option--${focused}` : false}
aria-owns={`${id}__listbox`}
aria-autocomplete={(this.hasAutoselect()) ? 'both' : 'list'}
{...ariaDescribedProp}
autoComplete='off'
className={`${inputClassName}${inputModifierFocused}${inputModifierType}`}
id={id}
Expand All @@ -479,7 +508,7 @@ export default class Autocomplete extends Component {
placeholder={placeholder}
ref={(inputElement) => { this.elementReferences[-1] = inputElement }}
type='text'
role='textbox'
role='combobox'
required={required}
value={query}
/>
Expand All @@ -496,12 +525,17 @@ export default class Autocomplete extends Component {
const showFocused = focused === -1 ? selected === index : focused === index
const optionModifierFocused = showFocused && hovered === null ? ` ${optionClassName}--focused` : ''
const optionModifierOdd = (index % 2) ? ` ${optionClassName}--odd` : ''
const iosPosinsetHtml = (isIosDevice())
? `<span id=${id}__option-suffix--${index} style="border:0;clip:rect(0 0 0 0);height:1px;` +
'marginBottom:-1px;marginRight:-1px;overflow:hidden;padding:0;position:absolute;' +
'whiteSpace:nowrap;width:1px">' + ` ${index + 1} of ${options.length}</span>`
: ''

return (
<li
aria-selected={focused === index}
className={`${optionClassName}${optionModifierFocused}${optionModifierOdd}`}
dangerouslySetInnerHTML={{ __html: this.templateSuggestion(option) }}
dangerouslySetInnerHTML={{ __html: this.templateSuggestion(option) + iosPosinsetHtml }}
id={`${id}__option--${index}`}
key={index}
onBlur={(event) => this.handleOptionBlur(event, index)}
Expand All @@ -510,6 +544,8 @@ export default class Autocomplete extends Component {
ref={(optionEl) => { this.elementReferences[index] = optionEl }}
role='option'
tabIndex='-1'
aria-posinset={index + 1}
aria-setsize={options.length}
/>
)
})}
Expand All @@ -518,6 +554,9 @@ export default class Autocomplete extends Component {
<li className={`${optionClassName} ${optionClassName}--no-results`}>{tNoResults()}</li>
)}
</ul>

<span id='assistiveHint' style={{ display: 'none' }}>{tAssistiveHint()}</span>

</div>
)
}
Expand Down
95 changes: 66 additions & 29 deletions src/status.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import { createElement, Component } from 'preact' /** @jsx createElement */

const debounce = function (func, wait, immediate) {
var timeout
return function () {
var context = this
var args = arguments
var later = function () {
timeout = null
if (!immediate) func.apply(context, args)
}
var callNow = immediate && !timeout
clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func.apply(context, args)
}
}
const statusDebounceMillis = 1400

export default class Status extends Component {
static defaultProps = {
tQueryTooShort: (minQueryLength) => `Type in ${minQueryLength} or more characters for results.`,
tNoResults: () => 'No search results.',
tSelectedOption: (selectedOption, length, index) => `${selectedOption} (${index + 1} of ${length}) is selected.`,
tQueryTooShort: (minQueryLength) => `Type in ${minQueryLength} or more characters for results`,
tNoResults: () => 'No search results',
tSelectedOption: (selectedOption, length, index) => `${selectedOption} ${index + 1} of ${length} is highlighted`,
tResults: (length, contentSelectedOption) => {
const words = {
result: (length === 1) ? 'result' : 'results',
Expand All @@ -16,14 +33,22 @@ export default class Status extends Component {
};

state = {
bump: false
bump: false,
debounced: false
}

componentWillMount () {
const that = this
this.debounceStatusUpdate = debounce(function () {
if (!that.state.debounced) {
const shouldSilence = !that.props.isInFocus || that.props.validChoiceMade
that.setState(({ bump }) => ({ bump: !bump, debounced: true, silenced: shouldSilence }))
}
}, statusDebounceMillis)
}

componentWillReceiveProps ({ queryLength }) {
const hasChanged = queryLength !== this.props.queryLength
if (hasChanged) {
this.setState(({ bump }) => ({ bump: !bump }))
}
this.setState({ debounced: false })
}

render () {
Expand All @@ -38,7 +63,7 @@ export default class Status extends Component {
tSelectedOption,
tResults
} = this.props
const { bump } = this.state
const { bump, debounced, silenced } = this.state

const queryTooShort = queryLength < minQueryLength
const noResults = length === 0
Expand All @@ -56,25 +81,37 @@ export default class Status extends Component {
content = tResults(length, contentSelectedOption)
}

return <div
aria-atomic='true'
aria-live='polite'
role='status'
style={{
border: '0',
clip: 'rect(0 0 0 0)',
height: '1px',
marginBottom: '-1px',
marginRight: '-1px',
overflow: 'hidden',
padding: '0',
position: 'absolute',
whiteSpace: 'nowrap',
width: '1px'
}}
>
{content}
<span>{bump ? ',' : ',,'}</span>
</div>
this.debounceStatusUpdate()

return (
<div
style={{
border: '0',
clip: 'rect(0 0 0 0)',
height: '1px',
marginBottom: '-1px',
marginRight: '-1px',
overflow: 'hidden',
padding: '0',
position: 'absolute',
whiteSpace: 'nowrap',
width: '1px'
}}>
<div
id='ariaLiveA'
role='status'
aria-atomic='true'
aria-live='polite'>
{(!silenced && debounced && bump) ? content : ''}
</div>
<div
id='ariaLiveB'
role='status'
aria-atomic='true'
aria-live='polite'>
{(!silenced && debounced && !bump) ? content : ''}
</div>
</div>
)
}
}
Loading

0 comments on commit 39c6176

Please sign in to comment.