Skip to content

Commit 5d1b20f

Browse files
committed
Transform to class to capsulate states
1 parent 93432ad commit 5d1b20f

File tree

3 files changed

+108
-120
lines changed

3 files changed

+108
-120
lines changed

examples/index.html

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,27 @@
2222
<pre class="events"></pre>
2323

2424
<script type="module">
25-
// import {install, uninstall, stop, start} from '../dist/index.js'
26-
import {install, uninstall, stop, start} from 'https://unpkg.com/@github/combobox-nav@latest/dist/index.js'
25+
// import Combobox from '../dist/index.js'
26+
import Combobox from 'https://unpkg.com/@github/combobox-nav@latest/dist/index.js'
2727
const input = document.querySelector('input')
2828
const list = document.querySelector('ul')
29-
install(input, list)
29+
const comboboxController = new Combobox(input, list)
3030

3131
function toggleList() {
3232
const hidden = input.value.length === 0
3333
if (hidden) {
34-
stop(input)
34+
comboboxController.stop()
3535
} else {
36-
start(input)
36+
comboboxController.start()
3737
}
3838
list.hidden = hidden
3939
}
4040
input.addEventListener('input', toggleList)
4141
input.addEventListener('focus', toggleList)
4242
input.addEventListener('blur', () => {
4343
list.hidden = true
44-
stop(input)
44+
comboboxController.clearSelection()
45+
comboboxController.stop()
4546
})
4647

4748
const events = document.querySelector('.events')

src/index.ts

Lines changed: 89 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,122 @@
1-
const comboboxStates = new WeakMap()
1+
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/)
22

3-
export function install(input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement): void {
4-
if (comboboxStates.get(input)) {
5-
uninstall(input)
6-
}
3+
export default class Combobox {
4+
isComposing: boolean
5+
list: HTMLElement
6+
input: HTMLTextAreaElement | HTMLInputElement
7+
keyboardEventHandler: (event: KeyboardEvent) => void
8+
compositionEventHandler: (event: Event) => void
9+
10+
constructor(input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement) {
11+
this.input = input
12+
this.list = list
13+
this.isComposing = false
14+
15+
if (!list.id) {
16+
list.id = `combobox-${Math.random()
17+
.toString()
18+
.slice(2, 6)}`
19+
}
720

8-
if (!list.id) {
9-
list.id = `combobox-${Math.random()
10-
.toString()
11-
.slice(2, 6)}`
21+
this.keyboardEventHandler = event => keyboardBindings(event, this)
22+
this.compositionEventHandler = event => trackComposition(event, this)
23+
input.setAttribute('role', 'combobox')
24+
input.setAttribute('aria-controls', list.id)
25+
input.setAttribute('aria-expanded', 'false')
26+
input.setAttribute('aria-autocomplete', 'list')
27+
input.setAttribute('aria-haspopup', 'listbox')
1228
}
1329

14-
input.setAttribute('role', 'combobox')
15-
input.setAttribute('aria-controls', list.id)
16-
input.setAttribute('aria-expanded', 'false')
17-
input.setAttribute('aria-autocomplete', 'list')
18-
input.setAttribute('aria-haspopup', 'listbox')
19-
comboboxStates.set(input, {list, isComposing: false})
20-
}
30+
destroy() {
31+
this.clearSelection()
32+
this.stop()
2133

22-
export function uninstall(input: HTMLTextAreaElement | HTMLInputElement): void {
23-
const {list} = comboboxStates.get(input) || {}
24-
if (!list) return
25-
clearSelection(input, list)
26-
stop(input)
27-
28-
input.removeAttribute('role')
29-
input.removeAttribute('aria-controls')
30-
input.removeAttribute('aria-expanded')
31-
input.removeAttribute('aria-autocomplete')
32-
input.removeAttribute('aria-haspopup')
33-
comboboxStates.delete(input)
34-
}
34+
this.input.removeAttribute('role')
35+
this.input.removeAttribute('aria-controls')
36+
this.input.removeAttribute('aria-expanded')
37+
this.input.removeAttribute('aria-autocomplete')
38+
this.input.removeAttribute('aria-haspopup')
39+
}
3540

36-
export function start(input: HTMLTextAreaElement | HTMLInputElement): void {
37-
const {list} = comboboxStates.get(input) || {}
38-
if (!list) return
41+
start(): void {
42+
this.input.setAttribute('aria-expanded', 'true')
43+
this.input.addEventListener('compositionstart', this.compositionEventHandler)
44+
this.input.addEventListener('compositionend', this.compositionEventHandler)
45+
;(this.input as HTMLElement).addEventListener('keydown', this.keyboardEventHandler)
46+
this.list.addEventListener('click', commitWithElement)
47+
}
3948

40-
input.setAttribute('aria-expanded', 'true')
41-
input.addEventListener('compositionstart', trackComposition)
42-
input.addEventListener('compositionend', trackComposition)
43-
;(input as HTMLInputElement).addEventListener('keydown', keyboardBindings)
44-
list.addEventListener('click', commitWithElement)
45-
}
49+
stop(): void {
50+
this.input.removeAttribute('aria-activedescendant')
51+
this.input.setAttribute('aria-expanded', 'false')
52+
this.input.removeEventListener('compositionstart', this.compositionEventHandler)
53+
this.input.removeEventListener('compositionend', this.compositionEventHandler)
54+
;(this.input as HTMLElement).removeEventListener('keydown', this.keyboardEventHandler)
55+
this.list.removeEventListener('click', commitWithElement)
56+
}
4657

47-
export function stop(input: HTMLTextAreaElement | HTMLInputElement): void {
48-
const {list} = comboboxStates.get(input) || {}
49-
if (!list) return
58+
navigate(indexDiff: -1 | 1 = 1): void {
59+
const focusEl = Array.from(this.list.querySelectorAll<HTMLElement>('[aria-selected="true"]')).filter(visible)[0]
60+
const els = Array.from(this.list.querySelectorAll<HTMLElement>('[role="option"]')).filter(visible)
61+
const focusIndex = els.indexOf(focusEl)
62+
let indexOfItem = indexDiff === 1 ? 0 : els.length - 1
63+
if (focusEl && focusIndex >= 0) {
64+
const newIndex = focusIndex + indexDiff
65+
if (newIndex >= 0 && newIndex < els.length) indexOfItem = newIndex
66+
}
5067

51-
input.removeAttribute('aria-activedescendant')
52-
input.setAttribute('aria-expanded', 'false')
53-
input.removeEventListener('compositionstart', trackComposition)
54-
input.removeEventListener('compositionend', trackComposition)
55-
;(input as HTMLInputElement).removeEventListener('keydown', keyboardBindings)
56-
list.removeEventListener('click', commitWithElement)
57-
}
68+
const target = els[indexOfItem]
69+
if (!target) return
70+
for (const el of els) {
71+
if (target === el) {
72+
this.input.setAttribute('aria-activedescendant', target.id)
73+
target.setAttribute('aria-selected', 'true')
74+
scrollTo(this.list, target)
75+
} else {
76+
el.setAttribute('aria-selected', 'false')
77+
}
78+
}
79+
}
5880

59-
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/)
81+
clearSelection(): void {
82+
this.input.removeAttribute('aria-activedescendant')
83+
for (const el of this.list.querySelectorAll('[aria-selected="true"]')) {
84+
el.setAttribute('aria-selected', 'false')
85+
}
86+
}
87+
}
6088

61-
function keyboardBindings(event: KeyboardEvent) {
89+
function keyboardBindings(event: KeyboardEvent, combobox: Combobox) {
6290
if (event.shiftKey || event.metaKey || event.altKey) return
63-
const input = event.currentTarget
64-
if (!(input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement)) return
65-
const {list, isComposing} = comboboxStates.get(input) || {}
66-
if (!list || isComposing) return
91+
if (combobox.isComposing) return
6792

6893
switch (event.key) {
6994
case 'Enter':
7095
case 'Tab':
71-
if (commit(input, list)) {
96+
if (commit(combobox.input, combobox.list)) {
7297
event.preventDefault()
7398
}
7499
break
75100
case 'Escape':
76-
clearSelection(input, list)
101+
combobox.clearSelection()
77102
break
78103
case 'ArrowDown':
79-
navigate(input, list, 1)
104+
combobox.navigate(1)
80105
event.preventDefault()
81106
break
82107
case 'ArrowUp':
83-
navigate(input, list, -1)
108+
combobox.navigate(-1)
84109
event.preventDefault()
85110
break
86111
case 'n':
87112
if (ctrlBindings && event.ctrlKey) {
88-
navigate(input, list, 1)
113+
combobox.navigate(1)
89114
event.preventDefault()
90115
}
91116
break
92117
case 'p':
93118
if (ctrlBindings && event.ctrlKey) {
94-
navigate(input, list, -1)
119+
combobox.navigate(-1)
95120
event.preventDefault()
96121
}
97122
break
@@ -126,51 +151,13 @@ function visible(el: HTMLElement): boolean {
126151
)
127152
}
128153

129-
export function navigate(
130-
input: HTMLTextAreaElement | HTMLInputElement,
131-
list: HTMLElement,
132-
indexDiff: -1 | 1 = 1
133-
): void {
134-
const focusEl = Array.from(list.querySelectorAll<HTMLElement>('[aria-selected="true"]')).filter(visible)[0]
135-
const els = Array.from(list.querySelectorAll<HTMLElement>('[role="option"]')).filter(visible)
136-
const focusIndex = els.indexOf(focusEl)
137-
let indexOfItem = indexDiff === 1 ? 0 : els.length - 1
138-
if (focusEl && focusIndex >= 0) {
139-
const newIndex = focusIndex + indexDiff
140-
if (newIndex >= 0 && newIndex < els.length) indexOfItem = newIndex
141-
}
142-
143-
const target = els[indexOfItem]
144-
if (!target) return
145-
for (const el of els) {
146-
if (target === el) {
147-
input.setAttribute('aria-activedescendant', target.id)
148-
target.setAttribute('aria-selected', 'true')
149-
scrollTo(list, target)
150-
} else {
151-
el.setAttribute('aria-selected', 'false')
152-
}
153-
}
154-
}
155-
156-
export function clearSelection(input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement): void {
157-
input.removeAttribute('aria-activedescendant')
158-
for (const el of list.querySelectorAll('[aria-selected="true"]')) {
159-
el.setAttribute('aria-selected', 'false')
160-
}
161-
}
162-
163-
function trackComposition(event: Event): void {
164-
const input = event.currentTarget
165-
if (!(input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement)) return
166-
const state = comboboxStates.get(input)
167-
if (!state) return
168-
state.isComposing = event.type === 'compositionstart'
154+
function trackComposition(event: Event, combobox: Combobox): void {
155+
combobox.isComposing = event.type === 'compositionstart'
169156

170-
const list = document.getElementById(input.getAttribute('aria-controls') || '')
157+
const list = document.getElementById(combobox.input.getAttribute('aria-controls') || '')
171158
if (!list) return
172159

173-
clearSelection(input, list)
160+
combobox.clearSelection()
174161
}
175162

176163
function scrollTo(container: HTMLElement, target: HTMLElement) {

test/test.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import {install, uninstall, stop, start, navigate, clearSelection} from '../dist/index.js'
2-
1+
import Combobox from '../dist/index.js'
32
function press(input, key, ctrlKey) {
43
input.dispatchEvent(new KeyboardEvent('keydown', {key, ctrlKey}))
54
}
@@ -30,26 +29,26 @@ describe('combobox-nav', function() {
3029
})
3130

3231
it('installs, starts, navigates, stops, and uninstalls', function() {
33-
install(input, list)
32+
const combobox = new Combobox(input, list)
3433
assert.equal(input.getAttribute('role'), 'combobox')
3534
assert.equal(input.getAttribute('aria-expanded'), 'false')
3635
assert.equal(input.getAttribute('aria-controls'), 'list-id')
3736
assert.equal(input.getAttribute('aria-autocomplete'), 'list')
3837
assert.equal(input.getAttribute('aria-haspopup'), 'listbox')
3938

40-
start(input)
39+
combobox.start()
4140
assert.equal(input.getAttribute('aria-expanded'), 'true')
4241

4342
press(input, 'ArrowDown')
4443
assert.equal(list.children[0].getAttribute('aria-selected'), 'true')
45-
navigate(input, list, 1)
44+
combobox.navigate(1)
4645
assert.equal(list.children[2].getAttribute('aria-selected'), 'true')
4746

48-
stop(input)
47+
combobox.stop()
4948
press(input, 'ArrowDown')
5049
assert.equal(list.children[2].getAttribute('aria-selected'), 'true')
5150

52-
uninstall(input)
51+
combobox.destroy()
5352
assert.equal(list.children[2].getAttribute('aria-selected'), 'false')
5453

5554
assert(!input.hasAttribute('role'))
@@ -61,7 +60,7 @@ describe('combobox-nav', function() {
6160
})
6261

6362
describe('with default setup', function() {
64-
let input, list, options
63+
let input, list, options, combobox
6564
beforeEach(function() {
6665
document.body.innerHTML = `
6766
<input type="text">
@@ -78,12 +77,13 @@ describe('combobox-nav', function() {
7877
input = document.querySelector('input')
7978
list = document.querySelector('ul')
8079
options = document.querySelectorAll('li')
81-
install(input, list)
82-
start(input)
80+
combobox = new Combobox(input, list)
81+
combobox.start()
8382
})
8483

8584
afterEach(function() {
86-
uninstall(document.querySelector('input'))
85+
combobox.destroy()
86+
combobox = null
8787
document.body.innerHTML = ''
8888
})
8989

@@ -160,7 +160,7 @@ describe('combobox-nav', function() {
160160
assert.equal(options[0].getAttribute('aria-selected'), 'true')
161161
assert.equal(input.getAttribute('aria-activedescendant'), 'baymax')
162162

163-
clearSelection(input, list)
163+
combobox.clearSelection()
164164

165165
assert.equal(options[0].getAttribute('aria-selected'), 'false')
166166
assert.equal(input.hasAttribute('aria-activedescendant'), false)

0 commit comments

Comments
 (0)