Skip to content

Commit 700b300

Browse files
authored
Merge pull request #21 from github/aria
[Breaking changes] Rework combobox-nav APIs and use ARIA 1.2
2 parents 4471e30 + a46916b commit 700b300

File tree

4 files changed

+117
-39
lines changed

4 files changed

+117
-39
lines changed

README.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Combobox Navigation
22

3-
Attach [combobox navigation behavior](https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html) to `<input>` or `<textarea>`.
3+
Attach [combobox navigation behavior (ARIA 1.2)](https://www.w3.org/TR/wai-aria-1.2/#combobox) to `<input>`.
44

55
## Installation
66

@@ -15,9 +15,9 @@ $ npm install @github/combobox-nav
1515
```html
1616
<label>
1717
Robot
18-
<input id="robot-input" aria-owns="list-id" role="combobox" type="text">
18+
<input id="robot-input" type="text">
1919
</label>
20-
<ul role="listbox" id="list-id">
20+
<ul role="listbox" id="list-id" hidden>
2121
<li id="baymax" role="option">Baymax</li>
2222
<li><del>BB-8</del></li><!-- `role=option` needs to be present for item to be selectable -->
2323
<li id="hubot" role="option">Hubot</li>
@@ -28,18 +28,23 @@ $ npm install @github/combobox-nav
2828
### JS
2929

3030
```js
31-
import {clearSelection, install, navigate, uninstall} from '@github/combobox-nav'
31+
import {clearSelection, install, navigate, start, stop, uninstall} from '@github/combobox-nav'
3232
const input = document.querySelector('#robot-input')
3333
const list = document.querySelector('#list-id')
3434

35-
// To install this behavior
35+
// install combobox pattern on a given input and listbox
3636
install(input, list)
37-
// To move selection to the nth+1 item in the list
37+
// when options appear, start intercepting keyboard events for navigation
38+
start(input)
39+
// when options disappear, stop intercepting keyboard events for navigation
40+
stop(input)
41+
42+
// move selection to the nth+1 item in the list
3843
navigate(input, list, 1)
39-
// To clear selection
44+
// reset selection
4045
clearSelection(input, list)
41-
// To uninstall this behavior
42-
uninstall(input, list)
46+
// uninstall combobox pattern from the input
47+
uninstall(input)
4348
```
4449

4550
## Events

examples/index.html

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,37 @@
1111
<form>
1212
<label>
1313
Least favorite robot
14-
<input aria-owns="list-id" role="combobox" type="text">
14+
<input aria-controls="list-id" role="combobox" type="text">
1515
</label>
16-
<ul role="listbox" id="list-id">
16+
<ul role="listbox" id="list-id" hidden>
1717
<li id="baymax" role="option">Baymax</li>
1818
<li id="bb-8" role="option" aria-disabled="true"><del>BB-8</del></li>
1919
<li id="hubot" role="option">Hubot</li>
2020
<li id="r2-d2" role="option">R2-D2</li>
21-
<li><a id="wall-e" href="#wall-e" role="option">Wall-E</a></li>
21+
<li role="presentation"><a id="wall-e" href="#wall-e" role="option">Wall-E</a></li>
2222
</ul>
2323
</form>
2424
<pre class="events"></pre>
2525
<script type="text/javascript">
26-
comboboxNav.install(document.querySelector('input'), document.querySelector('ul'))
26+
const input = document.querySelector('input')
27+
const list = document.querySelector('ul')
28+
comboboxNav.install(input, list)
29+
30+
function toggleList() {
31+
const hidden = input.value.length === 0
32+
if (hidden) {
33+
comboboxNav.stop(input)
34+
} else {
35+
comboboxNav.start(input)
36+
}
37+
list.hidden = hidden
38+
}
39+
input.addEventListener('input', toggleList)
40+
input.addEventListener('focus', toggleList)
41+
input.addEventListener('blur', () => {
42+
list.hidden = true
43+
comboboxNav.stop(input)
44+
})
2745

2846
const events = document.querySelector('.events')
2947
document.addEventListener('combobox-commit', function(event) {

src/combobox-nav.js

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,72 @@
22

33
import {scrollTo} from './scroll'
44

5+
const comboboxStates = new WeakMap()
6+
57
export function install(input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement): void {
8+
if (comboboxStates.get(input)) {
9+
uninstall(input)
10+
}
11+
12+
if (!list.id) {
13+
list.id = `combobox-${Math.random()
14+
.toString()
15+
.slice(2, 6)}`
16+
}
17+
18+
input.setAttribute('role', 'combobox')
19+
input.setAttribute('aria-controls', list.id)
20+
input.setAttribute('aria-expanded', 'false')
21+
input.setAttribute('aria-autocomplete', 'list')
22+
input.setAttribute('aria-haspopup', 'listbox')
23+
comboboxStates.set(input, {list, isComposing: false})
24+
}
25+
26+
export function uninstall(input: HTMLTextAreaElement | HTMLInputElement): void {
27+
const {list} = comboboxStates.get(input) || {}
28+
if (!list) return
29+
clearSelection(input, list)
30+
stop(input)
31+
32+
input.removeAttribute('role')
33+
input.removeAttribute('aria-controls')
34+
input.removeAttribute('aria-expanded')
35+
input.removeAttribute('aria-autocomplete')
36+
input.removeAttribute('aria-haspopup')
37+
comboboxStates.delete(input)
38+
}
39+
40+
export function start(input: HTMLTextAreaElement | HTMLInputElement): void {
41+
const {list} = comboboxStates.get(input) || {}
42+
if (!list) return
43+
44+
input.setAttribute('aria-expanded', 'true')
645
input.addEventListener('compositionstart', trackComposition)
746
input.addEventListener('compositionend', trackComposition)
847
input.addEventListener('keydown', keyboardBindings)
948
list.addEventListener('click', commitWithElement)
1049
}
1150

12-
export function uninstall(input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement): void {
51+
export function stop(input: HTMLTextAreaElement | HTMLInputElement): void {
52+
const {list} = comboboxStates.get(input) || {}
53+
if (!list) return
54+
1355
input.removeAttribute('aria-activedescendant')
56+
input.setAttribute('aria-expanded', 'false')
1457
input.removeEventListener('compositionstart', trackComposition)
1558
input.removeEventListener('compositionend', trackComposition)
1659
input.removeEventListener('keydown', keyboardBindings)
1760
list.removeEventListener('click', commitWithElement)
1861
}
1962

20-
let isComposing = false
2163
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/)
2264

2365
function keyboardBindings(event: KeyboardEvent) {
2466
if (event.shiftKey || event.metaKey || event.altKey) return
2567
const input = event.currentTarget
2668
if (!(input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement)) return
27-
if (isComposing) return
28-
const list = document.getElementById(input.getAttribute('aria-owns') || '')
29-
if (!list) return
69+
const {list, isComposing} = comboboxStates.get(input) || {}
70+
if (!list || isComposing) return
3071

3172
switch (event.key) {
3273
case 'Enter':
@@ -126,9 +167,11 @@ export function clearSelection(input: HTMLTextAreaElement | HTMLInputElement, li
126167
function trackComposition(event: Event): void {
127168
const input = event.currentTarget
128169
if (!(input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement)) return
129-
isComposing = event.type === 'compositionstart'
170+
const state = comboboxStates.get(input)
171+
if (!state) return
172+
state.isComposing = event.type === 'compositionstart'
130173

131-
const list = document.getElementById(input.getAttribute('aria-owns') || '')
174+
const list = document.getElementById(input.getAttribute('aria-controls') || '')
132175
if (!list) return
133176

134177
clearSelection(input, list)

test/test.js

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,61 @@ function click(element) {
88

99
describe('combobox-nav', function() {
1010
describe('with API', function() {
11+
let input, list
1112
beforeEach(function() {
1213
document.body.innerHTML = `
13-
<input aria-owns="list-id" role="combobox" type="text">
14+
<input type="text">
1415
<ul role="listbox" id="list-id">
1516
<li id="baymax" role="option">Baymax</li>
1617
<li><del>BB-8</del></li>
1718
<li id="hubot" role="option">Hubot</li>
1819
<li id="r2-d2" role="option">R2-D2</li>
1920
</ul>
2021
`
22+
input = document.querySelector('input')
23+
list = document.querySelector('ul')
2124
})
2225

2326
afterEach(function() {
2427
document.body.innerHTML = ''
2528
})
2629

27-
it('installs, navigates, and uninstalls', function() {
28-
const input = document.querySelector('input')
29-
const list = document.querySelector('ul')
30+
it('installs, starts, navigates, stops, and uninstalls', function() {
3031
comboboxNav.install(input, list)
32+
assert.equal(input.getAttribute('role'), 'combobox')
33+
assert.equal(input.getAttribute('aria-expanded'), 'false')
34+
assert.equal(input.getAttribute('aria-controls'), 'list-id')
35+
assert.equal(input.getAttribute('aria-autocomplete'), 'list')
36+
assert.equal(input.getAttribute('aria-haspopup'), 'listbox')
37+
38+
comboboxNav.start(input)
39+
assert.equal(input.getAttribute('aria-expanded'), 'true')
3140

3241
press(input, 'ArrowDown')
3342
assert.equal(list.children[0].getAttribute('aria-selected'), 'true')
3443
comboboxNav.navigate(input, list, 1)
3544
assert.equal(list.children[2].getAttribute('aria-selected'), 'true')
3645

37-
comboboxNav.uninstall(input, list)
38-
46+
comboboxNav.stop(input)
3947
press(input, 'ArrowDown')
4048
assert.equal(list.children[2].getAttribute('aria-selected'), 'true')
49+
50+
comboboxNav.uninstall(input)
51+
assert.equal(list.children[2].getAttribute('aria-selected'), 'false')
52+
53+
assert(!input.hasAttribute('role'))
54+
assert(!input.hasAttribute('aria-expanded'))
55+
assert(!input.hasAttribute('aria-controls'))
56+
assert(!input.hasAttribute('aria-autocomplete'))
57+
assert(!input.hasAttribute('aria-haspopup'))
4158
})
4259
})
4360

4461
describe('with default setup', function() {
62+
let input, list, options
4563
beforeEach(function() {
4664
document.body.innerHTML = `
47-
<input aria-owns="list-id" role="combobox" type="text">
65+
<input type="text">
4866
<ul role="listbox" id="list-id">
4967
<li id="baymax" role="option">Baymax</li>
5068
<li><del>BB-8</del></li>
@@ -55,17 +73,19 @@ describe('combobox-nav', function() {
5573
<li><a href="#wall-e" role="option">Wall-E</a></li>
5674
</ul>
5775
`
58-
comboboxNav.install(document.querySelector('input'), document.querySelector('ul'))
76+
input = document.querySelector('input')
77+
list = document.querySelector('ul')
78+
options = document.querySelectorAll('li')
79+
comboboxNav.install(input, list)
80+
comboboxNav.start(input)
5981
})
6082

6183
afterEach(function() {
62-
comboboxNav.uninstall(document.querySelector('input'), document.querySelector('ul'))
84+
comboboxNav.uninstall(document.querySelector('input'))
6385
document.body.innerHTML = ''
6486
})
6587

6688
it('updates attributes on keyboard events', function() {
67-
const input = document.querySelector('input')
68-
const options = document.querySelectorAll('li')
6989
const expectedTargets = []
7090

7191
document.addEventListener('combobox-commit', function({target}) {
@@ -107,7 +127,6 @@ describe('combobox-nav', function() {
107127
})
108128

109129
it('fires commit events on click', function() {
110-
const options = document.querySelectorAll('li')
111130
const expectedTargets = []
112131

113132
document.addEventListener('combobox-commit', function({target}) {
@@ -135,10 +154,6 @@ describe('combobox-nav', function() {
135154
})
136155

137156
it('clears aria-activedescendant and sets aria-selected=false when cleared', function() {
138-
const input = document.querySelector('input')
139-
const list = document.querySelector('ul')
140-
const options = document.querySelectorAll('li')
141-
142157
press(input, 'ArrowDown')
143158
assert.equal(options[0].getAttribute('aria-selected'), 'true')
144159
assert.equal(input.getAttribute('aria-activedescendant'), 'baymax')
@@ -150,12 +165,9 @@ describe('combobox-nav', function() {
150165
})
151166

152167
it('scrolls when the selected item is not in view', function() {
153-
const input = document.querySelector('input')
154-
const list = document.querySelector('ul')
155168
list.style.overflow = 'auto'
156169
list.style.height = '18px'
157170
list.style.position = 'relative'
158-
const options = document.querySelectorAll('li')
159171
assert.equal(list.scrollTop, 0)
160172

161173
press(input, 'ArrowDown')

0 commit comments

Comments
 (0)