Skip to content

Commit 3605e6e

Browse files
authored
Add option for selecting first item by default
1 parent 7704e9b commit 3605e6e

File tree

3 files changed

+97
-15
lines changed

3 files changed

+97
-15
lines changed

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ $ npm install @github/combobox-nav
1515
```html
1616
<label>
1717
Robot
18-
<input id="robot-input" type="text">
18+
<input id="robot-input" type="text" />
1919
</label>
2020
<ul role="listbox" id="list-id" hidden>
2121
<li id="baymax" role="option">Baymax</li>
22-
<li><del>BB-8</del></li><!-- `role=option` needs to be present for item to be selectable -->
22+
<li><del>BB-8</del></li>
23+
<!-- `role=option` needs to be present for item to be selectable -->
2324
<li id="hubot" role="option">Hubot</li>
2425
<li id="r2-d2" role="option">R2-D2</li>
2526
</ul>
@@ -59,27 +60,29 @@ A bubbling `combobox-commit` event is fired on the list element when an option i
5960
For example, autocomplete when an option is selected:
6061

6162
```js
62-
list.addEventListener('combobox-commit', function(event) {
63+
list.addEventListener('combobox-commit', function (event) {
6364
console.log('Element selected: ', event.target)
6465
})
6566
```
6667

67-
**Note:** When using `<label>` + `<input>` as options, please listen on `change` instead of `combobox-commit`.
68+
> **Note** When using `<label>` + `<input>` as options, please listen on `change` instead of `combobox-commit`.
6869
6970
When a label is clicked on, `click` event is fired from both `<label>` and its associated input `label.control`. Since combobox does not know about the control, `combobox-commit` cannot be used as an indicator of the item's selection state.
7071

7172
## Settings
7273

73-
For advanced configuration, the constructor takes an optional third argument. This is a settings object with the following setting:
74-
75-
- `tabInsertsSuggestions: boolean = true` - Control whether the highlighted suggestion is inserted when <kbd>Tab</kbd> is pressed (<kbd>Enter</kbd> will always insert a suggestion regardless of this setting). When `true`, tab-navigation will be hijacked when open (which can have negative impacts on accessibility) but the combobox will more closely imitate a native IDE experience.
76-
77-
For example:
74+
For advanced configuration, the constructor takes an optional third argument. For example:
7875

7976
```js
8077
const combobox = new Combobox(input, list, {tabInsertsSuggestions: true})
8178
```
8279

80+
These settings are available:
81+
82+
- `tabInsertsSuggestions: boolean = true` - Control whether the highlighted suggestion is inserted when <kbd>Tab</kbd> is pressed (<kbd>Enter</kbd> will always insert a suggestion regardless of this setting). When `true`, tab-navigation will be hijacked when open (which can have negative impacts on accessibility) but the combobox will more closely imitate a native IDE experience.
83+
- `defaultFirstOption: boolean = false` - If no options are selected and the user presses <kbd>Enter</kbd>, should the first item be inserted? If enabled, the default option can be selected and styled with `[data-combobox-option-default]` . This should be styled differently from the `aria-selected` option.
84+
> **Warning** Screen readers will not announce that the first item is the default. This should be announced explicitly with the use of `aria-live` status text.
85+
8386
## Development
8487

8588
```

src/index.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type ComboboxSettings = {
22
tabInsertsSuggestions?: boolean
3+
defaultFirstOption?: boolean
34
}
45

56
export default class Combobox {
@@ -11,15 +12,17 @@ export default class Combobox {
1112
inputHandler: (event: Event) => void
1213
ctrlBindings: boolean
1314
tabInsertsSuggestions: boolean
15+
defaultFirstOption: boolean
1416

1517
constructor(
1618
input: HTMLTextAreaElement | HTMLInputElement,
1719
list: HTMLElement,
18-
{tabInsertsSuggestions}: ComboboxSettings = {}
20+
{tabInsertsSuggestions, defaultFirstOption}: ComboboxSettings = {}
1921
) {
2022
this.input = input
2123
this.list = list
2224
this.tabInsertsSuggestions = tabInsertsSuggestions ?? true
25+
this.defaultFirstOption = defaultFirstOption ?? false
2326

2427
this.isComposing = false
2528

@@ -57,6 +60,7 @@ export default class Combobox {
5760
this.input.addEventListener('input', this.inputHandler)
5861
;(this.input as HTMLElement).addEventListener('keydown', this.keyboardEventHandler)
5962
this.list.addEventListener('click', commitWithElement)
63+
this.indicateDefaultOption()
6064
}
6165

6266
stop(): void {
@@ -69,6 +73,14 @@ export default class Combobox {
6973
this.list.removeEventListener('click', commitWithElement)
7074
}
7175

76+
indicateDefaultOption(): void {
77+
if (this.defaultFirstOption) {
78+
Array.from(this.list.querySelectorAll<HTMLElement>('[role="option"]:not([aria-disabled="true"])'))
79+
.filter(visible)[0]
80+
.setAttribute('data-combobox-option-default', 'true')
81+
}
82+
}
83+
7284
navigate(indexDiff: -1 | 1 = 1): void {
7385
const focusEl = Array.from(this.list.querySelectorAll<HTMLElement>('[aria-selected="true"]')).filter(visible)[0]
7486
const els = Array.from(this.list.querySelectorAll<HTMLElement>('[role="option"]')).filter(visible)
@@ -88,22 +100,26 @@ export default class Combobox {
88100

89101
const target = els[indexOfItem]
90102
if (!target) return
103+
91104
for (const el of els) {
105+
el.removeAttribute('data-combobox-option-default')
106+
92107
if (target === el) {
93108
this.input.setAttribute('aria-activedescendant', target.id)
94109
target.setAttribute('aria-selected', 'true')
95110
scrollTo(this.list, target)
96111
} else {
97-
el.setAttribute('aria-selected', 'false')
112+
el.removeAttribute('aria-selected')
98113
}
99114
}
100115
}
101116

102117
clearSelection(): void {
103118
this.input.removeAttribute('aria-activedescendant')
104119
for (const el of this.list.querySelectorAll('[aria-selected="true"]')) {
105-
el.setAttribute('aria-selected', 'false')
120+
el.removeAttribute('aria-selected')
106121
}
122+
this.indicateDefaultOption()
107123
}
108124
}
109125

@@ -161,7 +177,7 @@ function commitWithElement(event: MouseEvent) {
161177
}
162178

163179
function commit(input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement): boolean {
164-
const target = list.querySelector<HTMLElement>('[aria-selected="true"]')
180+
const target = list.querySelector<HTMLElement>('[aria-selected="true"], [data-combobox-option-default="true"]')
165181
if (!target) return false
166182
if (target.getAttribute('aria-disabled') === 'true') return true
167183
target.click()

test/test.js

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('combobox-nav', function () {
5454
assert(!list.querySelector('[aria-selected=true]'), 'Nothing should be selected')
5555

5656
combobox.destroy()
57-
assert.equal(list.children[2].getAttribute('aria-selected'), 'false')
57+
assert.equal(list.children[2].getAttribute('aria-selected'), null)
5858

5959
assert(!input.hasAttribute('role'))
6060
assert(!input.hasAttribute('aria-expanded'))
@@ -204,7 +204,7 @@ describe('combobox-nav', function () {
204204

205205
combobox.clearSelection()
206206

207-
assert.equal(options[0].getAttribute('aria-selected'), 'false')
207+
assert.equal(options[0].getAttribute('aria-selected'), null)
208208
assert.equal(input.hasAttribute('aria-activedescendant'), false)
209209
})
210210

@@ -226,4 +226,67 @@ describe('combobox-nav', function () {
226226
assert.equal(list.scrollTop, options[1].offsetTop)
227227
})
228228
})
229+
230+
describe('with defaulting to first option', function () {
231+
let input
232+
let list
233+
let options
234+
let combobox
235+
beforeEach(function () {
236+
document.body.innerHTML = `
237+
<input type="text">
238+
<ul role="listbox" id="list-id">
239+
<li id="baymax" role="option">Baymax</li>
240+
<li><del>BB-8</del></li>
241+
<li id="hubot" role="option">Hubot</li>
242+
<li id="r2-d2" role="option">R2-D2</li>
243+
<li id="johnny-5" hidden role="option">Johnny 5</li>
244+
<li id="wall-e" role="option" aria-disabled="true">Wall-E</li>
245+
<li><a href="#link" role="option" id="link">Link</a></li>
246+
</ul>
247+
`
248+
input = document.querySelector('input')
249+
list = document.querySelector('ul')
250+
options = document.querySelectorAll('[role=option]')
251+
combobox = new Combobox(input, list, {defaultFirstOption: true})
252+
combobox.start()
253+
})
254+
255+
afterEach(function () {
256+
combobox.destroy()
257+
combobox = null
258+
document.body.innerHTML = ''
259+
})
260+
261+
it('indicates first option when started', () => {
262+
assert.equal(document.querySelector('[data-combobox-option-default]'), options[0])
263+
assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 1)
264+
})
265+
266+
it('indicates first option when restarted', () => {
267+
combobox.stop()
268+
combobox.start()
269+
assert.equal(document.querySelector('[data-combobox-option-default]'), options[0])
270+
})
271+
272+
it('applies default option on Enter', () => {
273+
let commits = 0
274+
document.addEventListener('combobox-commit', () => commits++)
275+
276+
assert.equal(commits, 0)
277+
press(input, 'Enter')
278+
assert.equal(commits, 1)
279+
})
280+
281+
it('clears default indication when navigating', () => {
282+
combobox.navigate(1)
283+
assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 0)
284+
})
285+
286+
it('resets default indication when selection cleared', () => {
287+
combobox.navigate(1)
288+
combobox.clearSelection()
289+
assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 1)
290+
})
291+
})
229292
})

0 commit comments

Comments
 (0)