Skip to content

Commit e9bfd14

Browse files
authored
[Accessibility] Adds support for an accessible "clear" button (#53)
If an element with <input-id>-clear is detected in the autocomplete, clicking that button will: clear the associated input focus back on the input (a11y) announce that the input has been cleared (a11y)
1 parent b461d77 commit e9bfd14

4 files changed

Lines changed: 113 additions & 13 deletions

File tree

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,15 @@ With a script tag:
2828
2929
```html
3030
<auto-complete src="/users/search" for="users-popup">
31-
<input type="text">
31+
<input type="text" name="users">
32+
<!--
33+
Optional clear button:
34+
- id must match the id of the input or the name of the input plus "-clear"
35+
- recommended to be *before* UL elements to avoid conflicting with their blur logic
36+
37+
Please see Note below on this button for more details
38+
-->
39+
<button id="users-clear">X</button>
3240
<ul id="users-popup"></ul>
3341
<!--
3442
Optional div for screen reader feedback. Note the ID matches the ul, but with -feedback appended.
@@ -61,6 +69,12 @@ item whose display text needs to be different:
6169
<li role="option" data-autocomplete-value="bb8">BB-8 (astromech)</li>
6270
```
6371
72+
### A Note on Clear button
73+
While `input type="search"` comes with an `x` that clears the content of the field and refocuses it on many browsers, the implementation for this control is not keyboard accessible, and so we've opted to enable a customizable clear button so that your keyboard users will be able to interact with it.
74+
75+
As an example:
76+
> In Chrome, this 'x' isn't a button but a div with a pseudo="-webkit-search-cancel-button". It doesn't have a tab index or a way to navigate to it without a mouse. Additionally, this control is only visible on mouse hover.
77+
6478
## Attributes
6579
6680
- `open` is true when the auto-complete result list is visible

examples/index.html

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@
3939
</head>
4040
<body>
4141
<form>
42-
<label id="robots-label">Robots</label>
42+
<label id="robots-label" for="robot">Robots</label>
4343
<!-- To enable auto-select (select first on Enter), use data-autoselect="true" -->
4444
<auto-complete src="/demo" for="items-popup" aria-labelledby="robots-label" data-autoselect="true">
4545
<input name="robot" type="text" aria-labelledby="robots-label" autofocus>
46-
<ul id="items-popup" aria-labelledby="robots-label"></ul>
46+
<!-- if a clear button is passed in, recommended to be *before* UL elements to avoid conflicting with their blur logic -->
47+
<button id="robot-clear">x</button>
48+
<ul id="items-popup"></ul>
4749
<!-- For built-in screen-reader announcements:
4850
- Note the ID is the same as the <ul> with "feedback" appended
4951
- Also note that aria attributes will be added programmatically if they aren't set correctly
@@ -53,12 +55,30 @@
5355
<button type="submit">Save</button>
5456
</form>
5557

58+
<!-- example where clear button uses input id -->
59+
<form>
60+
<label id="robots-a-label" for="robot-a">Robots (using Input ID)</label>
61+
<!-- To enable auto-select (select first on Enter), use data-autoselect="true" -->
62+
<auto-complete src="/demo" for="items-a-popup" aria-labelledby="robots-a-label" data-autoselect="true">
63+
<input id="robot-a" name="robot-a" type="text" aria-labelledby="robots-a-label" autofocus>
64+
<!-- if a clear button is passed in, recommended to be *before* UL elements to avoid conflicting with their blur logic -->
65+
<button id="robot-a-clear">x</button>
66+
<ul id="items-a-popup"></ul>
67+
<!-- For built-in screen-reader announcements:
68+
- Note the ID is the same as the <ul> with "feedback" appended
69+
- Also note that aria attributes will be added programmatically if they aren't set correctly
70+
-->
71+
<div id="items-a-popup-feedback" class="sr-only"></div>
72+
</auto-complete>
73+
<button type="submit">Save</button>
74+
</form>
75+
5676
<!-- example without autoselect -->
5777
<form>
58-
<label id="robots-2-label">Robots 2</label>
78+
<label id="robots-2-label" for="robot-2">Robots (without autoselect on enter)</label>
5979
<auto-complete src="/demo" for="items-2-popup" aria-labelledby="robots-2-label" >
60-
<input name="robot" type="text" aria-labelledby="robots-2-label" autofocus>
61-
<ul id="items-2-popup" aria-labelledby="robots-2-label"></ul>
80+
<input name="robot-2" type="text" aria-labelledby="robots-2-label" autofocus>
81+
<ul id="items-2-popup"></ul>
6282
<div id="items-2-popup-feedback" class="sr-only"></div>
6383
</auto-complete>
6484
<button type="submit">Save</button>

src/autocomplete.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default class Autocomplete {
1515
feedback: HTMLElement | null
1616
autoselectEnabled: boolean
1717
clientOptions: NodeListOf<HTMLElement> | null
18+
clearButton: HTMLElement | null
1819

1920
interactingWithList: boolean
2021

@@ -30,17 +31,32 @@ export default class Autocomplete {
3031
this.combobox = new Combobox(input, results)
3132
this.feedback = document.getElementById(`${this.results.id}-feedback`)
3233
this.autoselectEnabled = autoselectEnabled
34+
this.clearButton = document.getElementById(`${this.input.id || this.input.name}-clear`)
3335

3436
// check to see if there are any default options provided
3537
this.clientOptions = results.querySelectorAll('[role=option]')
3638

3739
// make sure feedback has all required aria attributes
3840
if (this.feedback) {
39-
this.feedback.setAttribute('aria-live', 'assertive')
41+
this.feedback.setAttribute('aria-live', 'polite')
4042
this.feedback.setAttribute('aria-atomic', 'true')
4143
}
4244

45+
// if clearButton doesn't have an accessible label, give it one
46+
if (this.clearButton && !this.clearButton.getAttribute('aria-label')) {
47+
const labelElem = document.querySelector(`label[for="${this.input.name}"]`)
48+
this.clearButton.setAttribute('aria-label', `clear:`)
49+
this.clearButton.setAttribute('aria-labelledby', `${this.clearButton.id} ${labelElem?.id || ''}`)
50+
}
51+
52+
// initialize with the input being expanded=false
53+
if (!this.input.getAttribute('aria-expanded')) {
54+
this.input.setAttribute('aria-expanded', 'false')
55+
}
56+
4357
this.results.hidden = true
58+
// @jscholes recommends a generic "results" label as the results are already related to the combobox, which is properly labelled
59+
this.results.setAttribute('aria-label', 'results')
4460
this.input.setAttribute('autocomplete', 'off')
4561
this.input.setAttribute('spellcheck', 'false')
4662

@@ -52,13 +68,15 @@ export default class Autocomplete {
5268
this.onInputFocus = this.onInputFocus.bind(this)
5369
this.onKeydown = this.onKeydown.bind(this)
5470
this.onCommit = this.onCommit.bind(this)
71+
this.handleClear = this.handleClear.bind(this)
5572

5673
this.input.addEventListener('keydown', this.onKeydown)
5774
this.input.addEventListener('focus', this.onInputFocus)
5875
this.input.addEventListener('blur', this.onInputBlur)
5976
this.input.addEventListener('input', this.onInputChange)
6077
this.results.addEventListener('mousedown', this.onResultsMouseDown)
6178
this.results.addEventListener('combobox-commit', this.onCommit)
79+
this.clearButton?.addEventListener('click', this.handleClear)
6280
}
6381

6482
destroy(): void {
@@ -70,6 +88,21 @@ export default class Autocomplete {
7088
this.results.removeEventListener('combobox-commit', this.onCommit)
7189
}
7290

91+
handleClear(event: Event): void {
92+
event.preventDefault()
93+
94+
if (this.input.getAttribute('aria-expanded') === 'true') {
95+
this.input.setAttribute('aria-expanded', 'false')
96+
this.updateFeedbackForScreenReaders('Results hidden.')
97+
}
98+
99+
this.input.value = ''
100+
this.container.value = ''
101+
this.input.focus()
102+
this.input.dispatchEvent(new Event('change'))
103+
this.container.open = false
104+
}
105+
73106
onKeydown(event: KeyboardEvent): void {
74107
// if autoselect is enabled, Enter key will select the first option
75108
if (event.key === 'Enter' && this.container.open && this.autoselectEnabled) {
@@ -120,7 +153,7 @@ export default class Autocomplete {
120153
this.container.value = value
121154

122155
if (!value) {
123-
this.updateFeedbackForScreenReaders(`Suggestions hidden.`)
156+
this.updateFeedbackForScreenReaders(`Results hidden.`)
124157
}
125158
}
126159

@@ -175,15 +208,15 @@ export default class Autocomplete {
175208
const hasResults = !!allNewOptions.length
176209
const numOptions = allNewOptions.length
177210

178-
// inform SR users of which element is "on-deck" so that it's clear what Enter will do
179211
const [firstOption] = allNewOptions
180212
const firstOptionValue = firstOption?.textContent
181213
if (this.autoselectEnabled && firstOptionValue) {
214+
// inform SR users of which element is "on-deck" so that it's clear what Enter will do
182215
this.updateFeedbackForScreenReaders(
183-
`${numOptions} suggested options. Press Enter to select ${firstOptionValue}.`
216+
`${numOptions} results. ${firstOptionValue} is the top result: Press Enter to activate.`
184217
)
185218
} else {
186-
this.updateFeedbackForScreenReaders(`${numOptions} suggested options.`)
219+
this.updateFeedbackForScreenReaders(`${numOptions || 'No'} results.`)
187220
}
188221

189222
this.container.open = hasResults

test/test.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe('auto-complete element', function () {
8989
await once(container, 'loadend')
9090
await waitForElementToChange(feedback)
9191

92-
assert.equal('5 suggested options.', feedback.innerHTML)
92+
assert.equal('5 results.', feedback.innerHTML)
9393
})
9494

9595
it('commits on Enter', async function () {
@@ -196,6 +196,39 @@ describe('auto-complete element', function () {
196196
})
197197
})
198198

199+
describe('clear button provided', () => {
200+
it('clears the input value on click and gives focus back to the input', async () => {
201+
document.body.innerHTML = `
202+
<div id="mocha-fixture">
203+
<auto-complete src="/search" for="popup" data-autoselect="true">
204+
<input id="example" type="text">
205+
<button id="example-clear">x</button>
206+
<ul id="popup"></ul>
207+
<div id="popup-feedback"></div>
208+
</auto-complete>
209+
</div>
210+
`
211+
212+
const container = document.querySelector('auto-complete')
213+
const input = container.querySelector('input')
214+
const clearButton = document.getElementById('example-clear')
215+
const feedback = container.querySelector(`#popup-feedback`)
216+
217+
triggerInput(input, 'hub')
218+
await once(container, 'loadend')
219+
220+
assert.equal(input.value, 'hub')
221+
await waitForElementToChange(feedback)
222+
223+
clearButton.click()
224+
assert.equal(input.value, '')
225+
assert.equal(container.value, '')
226+
await waitForElementToChange(feedback)
227+
assert.equal('Results hidden.', feedback.innerHTML)
228+
assert.equal(document.activeElement, input)
229+
})
230+
})
231+
199232
describe('autoselect enabled', () => {
200233
beforeEach(function () {
201234
document.body.innerHTML = `
@@ -218,7 +251,7 @@ describe('auto-complete element', function () {
218251
await once(container, 'loadend')
219252
await waitForElementToChange(feedback)
220253

221-
assert.equal(`5 suggested options. Press Enter to select first.`, feedback.innerHTML)
254+
assert.equal(`5 results. first is the top result: Press Enter to activate.`, feedback.innerHTML)
222255
})
223256
})
224257
})

0 commit comments

Comments
 (0)