Autosuggests offer a number of possible values, usually presented as some sort of a dropdown element, allowing to select one. By entering a filter string, the possible values are filtered.
Best known from search fields (like Google or YouTube), autocompletes immediately offer suggestions based on the user's input.
We do not call autosuggests "autocompletes" so the difference to HTML's autocomplete attribute is obvious.
General requirements
The following requirements are based on well established best practices; unlike most other common widget patterns, the WAI-ARIA Authoring Practices do not offer a section about autosuggests.
Besides many other requirements, we want to stress out explicitly the following:
The meaning and usage of the autosuggest must be clear.
If possible, the total number of suggestions should be perceivable ("3 suggestions in total" or similar).
Proper feedback must be given upon entering a filter ("2 suggestions available for X" or similar).
The autosuggest must be operable using both keyboard only and desktop screen readers (with a reasonable interplay of default keys like Tab, Enter/Space, Esc, Arrow keys), as well as mobile screen readers.
According to our credo Widgets simply working for all, we advise to create autosuggests as combination of a text input, acting as filter, and a group of radio buttons, acting as the options. They can be styled visually as needed using CSS, and spiced up with (very little) JavaScript, so they behave like perfect autosuggests.
Sensible naming of elements (and a few specifically added visually hidden texts and alerts) guarantees that screen reader users know how to handle the element - even if they have not seen any other autosuggest before.
;(function () {
// Tested in JAWS+IE/FF, NVDA+FF// Known issues:// - JAWS leaves the input when using up/down without entering something (I guess this is due to screen layout and can be considered intended)// - Alert not perceivable upon opening options using up/down// - Possible solution 1: always show options count when filter focused?// - Possible solution 2: wait a moment before adding the alert?// - VoiceOver/iOS announces radio buttons as disabled?!// - iOS doesn't select all text when option was chosen// In general: alerts seem to be most robust in all relevant browsers, but aren't polite. Maybe we'll find a better mechanism to serve browsers individually?varAdgAutocompleteAdgAutocomplete = function () {
var config, uniqueIdCount
classAdgAutocomplete {
constructor(el, options = {}) {
var jsonOptions, key, val
this.$el = $(el)
this.config = config
for (key in options) {
val = options[key]
this.config[key] = val
}
jsonOptions = this.$el.attr(this.adgDataAttributeName())
if (jsonOptions) {
for (key in jsonOptions) {
val = jsonOptions[key]
this.config[key] = val
}
}
this.debugMessage('start')
this.initFilter()
this.initOptions()
this.initAlerts()
this.applyCheckedOptionToFilter()
this.announceOptionsNumber('')
this.attachEvents()
}
// Prints the given message to the console if config['debug'] is true.debugMessage(message) {
if (this.config.debugMessage) {
returnconsole.log(`Adg debug: ${message}`)
}
}
// Executes the given selector on @$el and returns the element. Makes sure exactly one element exists.findOne(selector) {
var result
result = this.$el.find(selector)
switch (result.length) {
case0:
returnthis.throwMessageAndPrintObjectsToConsole(
`No object found for ${selector}!`,
{
result: result
}
)
case1:
return $(result.first())
default:
returnthis.throwMessageAndPrintObjectsToConsole(
`More than one object found for ${selector}!`,
{
result: result
}
)
}
}
name() {
return'adg-autosuggest'
}
addAdgDataAttribute($target, name, value = '') {
return $target.attr(this.adgDataAttributeName(name), value)
}
removeAdgDataAttribute($target, name) {
return $target.removeAttr(this.adgDataAttributeName(name))
}
adgDataAttributeName(name = null) {
var result
result = `data-${this.name()}`if (name) {
result += `-${name}`
}
return result
}
uniqueId(name) {
return [this.name(), name, uniqueIdCount++].join('-')
}
labelOfInput($inputs) {
return $inputs.map((i, input) => {
var $input, $label, id
$input = $(input)
id = $input.attr('id')
$label = this.findOne(`label[for='${id}']`)[0]
if ($label.length === 0) {
$label = $input.closest('label')
if ($label.length === 0) {
this.throwMessageAndPrintObjectsToConsole(
'No corresponding input found for input!',
{
input: $input
}
)
}
}
return $label
})
}
show($el) {
$el.removeAttr('hidden')
return $el.show()
}
// TODO Would be cool to renounce CSS and solely use the hidden attribute. But jQuery's :visible doesn't seem to work with it!?// @throwMessageAndPrintObjectsToConsole("Element is still hidden, although hidden attribute was removed! Make sure there's no CSS like display:none or visibility:hidden left on it!", element: $el) if $el.is(':hidden')hide($el) {
$el.attr('hidden', '')
return $el.hide()
}
throwMessageAndPrintObjectsToConsole(message, elements = {}) {
console.log(elements)
throw message
}
text(text, options = {}) {
var key, value
text = this.config[`${text}Text`]
for (key in options) {
value = options[key]
text = text.replace(`[${key}]`, value)
}
return text
}
initFilter() {
this.$filter = this.findOne('input[type="text"]')
this.addAdgDataAttribute(this.$filter, 'filter')
this.$filter.attr('autocomplete', 'off')
returnthis.$filter.attr('aria-expanded', 'false')
}
initOptions() {
this.$optionsContainer = this.findOne(this.config.optionsContainer)
this.addAdgDataAttribute(this.$optionsContainer, 'options')
this.$optionsContainerLabel = this.findOne(
this.config.optionsContainerLabel
)
this.$optionsContainerLabel.addClass(this.config.hiddenCssClass)
this.$options = this.$optionsContainer.find('input[type="radio"]')
this.addAdgDataAttribute(this.labelOfInput(this.$options), 'option')
returnthis.$options.addClass(this.config.hiddenCssClass)
}
initAlerts() {
this.$alertsContainer = $(
`<div id='${this.uniqueId(this.config.alertsContainerId)}'></div>`
)
this.$optionsContainerLabel.after(this.$alertsContainer)
this.$filter.attr(
'aria-describedby',
[
this.$filter.attr('aria-describedby'),
this.$alertsContainer.attr('id')
]
.join(' ')
.trim()
)
returnthis.addAdgDataAttribute(this.$alertsContainer, 'alerts')
}
attachEvents() {
this.attachClickEventToFilter()
this.attachChangeEventToFilter()
this.attachEscapeKeyToFilter()
this.attachEnterKeyToFilter()
this.attachTabKeyToFilter()
this.attachUpDownKeysToFilter()
this.attachChangeEventToOptions()
returnthis.attachClickEventToOptions()
}
attachClickEventToFilter() {
returnthis.$filter.click(() => {
this.debugMessage('click filter')
if (this.$optionsContainer.is(':visible')) {
returnthis.hideOptions()
} else {
returnthis.showOptions()
}
})
}
attachEscapeKeyToFilter() {
returnthis.$filter.keydown(e => {
if (e.which === 27) {
if (this.$optionsContainer.is(':visible')) {
this.applyCheckedOptionToFilterAndResetOptions()
return e.preventDefault()
} elseif (this.$options.is(':checked')) {
this.$options.prop('checked', false)
this.applyCheckedOptionToFilterAndResetOptions()
return e.preventDefault() // Needed for automatic testing only
} else {
return $('body').append('<p>Esc passed on.</p>')
}
}
})
}
attachEnterKeyToFilter() {
returnthis.$filter.keydown(e => {
if (e.which === 13) {
this.debugMessage('enter')
if (this.$optionsContainer.is(':visible')) {
this.applyCheckedOptionToFilterAndResetOptions()
return e.preventDefault() // Needed for automatic testing only
} else {
return $('body').append('<p>Enter passed on.</p>')
}
}
})
}
attachTabKeyToFilter() {
returnthis.$filter.keydown(e => {
if (e.which === 9) {
this.debugMessage('tab')
if (this.$optionsContainer.is(':visible')) {
returnthis.applyCheckedOptionToFilterAndResetOptions()
}
}
})
}
attachUpDownKeysToFilter() {
returnthis.$filter.keydown(e => {
if (e.which === 38 || e.which === 40) {
if (this.$optionsContainer.is(':visible')) {
if (e.which === 38) {
this.moveSelection('up')
} else {
this.moveSelection('down')
}
} else {
this.showOptions()
}
return e.preventDefault() // TODO: Test!
}
})
}
showOptions() {
this.debugMessage('(show options)')
this.show(this.$optionsContainer)
returnthis.$filter.attr('aria-expanded', 'true')
}
hideOptions() {
this.debugMessage('(hide options)')
this.hide(this.$optionsContainer)
returnthis.$filter.attr('aria-expanded', 'false')
}
moveSelection(direction) {
var $upcomingOption,
$visibleOptions,
currentIndex,
maxIndex,
upcomingIndex
$visibleOptions = this.$options.filter(':visible')
maxIndex = $visibleOptions.length - 1
currentIndex = $visibleOptions.index(
$visibleOptions.parent().find(':checked')
) // TODO: is parent() good here?!
upcomingIndex =
direction === 'up'
? currentIndex <= 0
? maxIndex
: currentIndex - 1
: currentIndex === maxIndex
? 0
: currentIndex + 1
$upcomingOption = $($visibleOptions[upcomingIndex])
return $upcomingOption.prop('checked', true).trigger('change')
}
attachChangeEventToOptions() {
returnthis.$options.change(e => {
this.debugMessage('option change')
this.applyCheckedOptionToFilter()
returnthis.$filter.focus().select()
})
}
applyCheckedOptionToFilterAndResetOptions() {
this.applyCheckedOptionToFilter()
this.hideOptions()
returnthis.filterOptions()
}
applyCheckedOptionToFilter() {
var $checkedOption, $checkedOptionLabel, $previouslyCheckedOptionLabel
this.debugMessage('(apply option to filter)')
$previouslyCheckedOptionLabel = $(
`[${this.adgDataAttributeName('option-selected')}]`
)
if ($previouslyCheckedOptionLabel.length === 1) {
this.removeAdgDataAttribute(
$previouslyCheckedOptionLabel,
'option-selected'
)
}
$checkedOption = this.$options.filter(':checked')
if ($checkedOption.length === 1) {
$checkedOptionLabel = this.labelOfInput($checkedOption)
this.$filter.val($.trim($checkedOptionLabel.text()))
returnthis.addAdgDataAttribute(
$checkedOptionLabel,
'option-selected'
)
} else {
returnthis.$filter.val('')
}
}
attachClickEventToOptions() {
returnthis.$options.click(e => {
this.debugMessage('click option')
returnthis.hideOptions()
})
}
attachChangeEventToFilter() {
returnthis.$filter.on('input propertychange paste', e => {
this.debugMessage('(filter changed)')
this.filterOptions(e.target.value)
returnthis.showOptions()
})
}
filterOptions(filter = '') {
var fuzzyFilter, visibleNumber
fuzzyFilter = this.fuzzifyFilter(filter)
visibleNumber = 0this.$options.each((i, el) => {
var $option, $optionContainer, regex
$option = $(el)
$optionContainer = $option.parent()
regex = newRegExp(fuzzyFilter, 'i')
if (regex.test($optionContainer.text())) {
visibleNumber++
returnthis.show($optionContainer)
} else {
returnthis.hide($optionContainer)
}
})
returnthis.announceOptionsNumber(filter, visibleNumber)
}
announceOptionsNumber(
filter = this.$filter.val(),
number = this.$options.length
) {
var message
this.$alertsContainer.find('p').remove() // Remove previous alerts (I'm not sure whether this is the best solution, maybe hiding them would be more robust?)
message =
filter === ''
? this.text('numberInTotal', {
number: number
})
: this.text('numberFiltered', {
number: number,
total: this.$options.length,
filter: `<kbd>${filter}</kbd>`
})
returnthis.$alertsContainer.append(`<p role='alert'>${message}</p>`)
}
fuzzifyFilter(filter) {
var escapedCharacter, fuzzifiedFilter, i
i = 0
fuzzifiedFilter = ''while (i < filter.length) {
escapedCharacter = filter
.charAt(i)
.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&') // See https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
fuzzifiedFilter += `${escapedCharacter}.*?`
i++
}
return fuzzifiedFilter
}
}
uniqueIdCount = 1
config = {
debugMessage: false,
hiddenCssClass: 'adg-visually-hidden',
optionsContainer: 'fieldset',
optionsContainerLabel: 'legend',
alertsContainerId: 'alerts',
numberInTotalText: '[number] options in total',
numberFilteredText: '[number] of [total] options for [filter]'
}
returnAdgAutocomplete
}.call(this)
$(document).ready(function () {
return $('[data-adg-autosuggest]').each(function () {
returnnewAdgAutocomplete(this)
})
})
}).call(this)
Category
Result
Comments
Date
Keyboard only
✔ (pass) pass
-
2018-5-29
NVDA 2023.1 + FF 115
✔ (pass) pass
-
2023-8-3
NVDA 2021.2 + Chrome
✔ (pass) pass
-
2021-2-10
NVDA 2023.1 + Edge
✔ (pass) pass
-
2023-7-13
JAWS 2018.3 + FF ESR 52.7.3
✔ (pass) pass
-
2018-5-22
JAWS 2021.2 + Chrome
✔ (pass) pass
-
2021-2-10
JAWS 2023.1 + Edge
✔ (pass) pass
-
2023-7-13
Implementation details
Some interesting peculiarities:
The filter input has:
A descriptive text attached to it using aria-describedby (see Adding descriptions to elements using aria-describedby), giving a clue that the element provides suggestions upon entering text, and how many options there are available.
In the background, the radio button values are toggled using JavaScript, and the currently selected radio button's label is entered into the filter (which itself leads screen readers to announce the filter's new value).
In our autosuggest widget we have bound these keys to toggle through the displayed results. And although preventing the default action upon pressing those keys (using JavaScript's event.preventDefault()), JAWS (sometimes) does not respect this and leaves the text input.
This is an unpleasant situation, but definitely an expected behaviour from the screen reader perspective. Sadly, most screen reader users are not aware of such subtleties and can be very confused in situations like this.
In our case, the situation is mitigated because:
The suggested options are displayed right below the text input, so when JAWS "accidentally" leaves the text input, the options are found immediately.
The suggested options are a group of radio buttons that can be interacted with perfectly.