Skip to content

Commit 6a87759

Browse files
author
Andrew Leach
authored
Merge pull request #81 from github/add-new-option
Add option to auto navigate to the first item on open
2 parents f7aeecf + 5c6bd76 commit 6a87759

File tree

3 files changed

+94
-10
lines changed

3 files changed

+94
-10
lines changed

README.md

+5-2
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,11 @@ const combobox = new Combobox(input, list, {tabInsertsSuggestions: true})
9090
These settings are available:
9191

9292
- `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.
93-
- `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.
94-
> **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.
93+
- `firstOptionSelectionMode: FirstOptionSelectionMode = 'none'` - This option dictates the default behaviour when no options have been selected yet and the user presses <kbd>Enter</kbd>. The following values of `FirstOptionSelectionMode` will do the following:
94+
- `'none'`: Don't auto-select the first option at all.
95+
- `'active'`: Place the first option in an 'active' state where it is not selected (is not the `aria-activedescendant`) but will still be applied if the user presses `Enter`. To select the second item, the user would need to press the down arrow twice. This approach allows quick application of selections without disrupting screen reader users.
96+
> **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
97+
- `'selected'`: Select the first item by navigating to it. This allows quick application of selections and makes it faster to select the second item, but can be disruptive or confusing for screen reader users.
9598
- `scrollIntoViewOptions?: boolean | ScrollIntoViewOptions = undefined` - When
9699
controlling the element marked `[aria-selected="true"]` with keyboard navigation, the selected element will be scrolled into the viewport by a call to [Element.scrollIntoView][]. Configure this value to control the scrolling behavior (either with a `boolean` or a [ScrollIntoViewOptions][] object.
97100

src/index.ts

+27-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
export type ComboboxSettings = {
22
tabInsertsSuggestions?: boolean
3-
defaultFirstOption?: boolean
3+
/**
4+
* Indicates the default behaviour for the first option when the list is shown:
5+
*
6+
* - `'none'`: Don't auto-select the first option at all.
7+
* - `'active'`: Place the first option in an 'active' state where it is not
8+
* selected (is not the `aria-activedescendant`) but will still be applied
9+
* if the user presses `Enter`. To select the second item, the user would
10+
* need to press the down arrow twice. This approach allows quick application
11+
* of selections without disrupting screen reader users.
12+
* - `'selected'`: Select the first item by navigating to it. This allows quick
13+
* application of selections and makes it faster to select the second item,
14+
* but can be disruptive or confusing for screen reader users.
15+
*/
16+
firstOptionSelectionMode?: FirstOptionSelectionMode
417
scrollIntoViewOptions?: boolean | ScrollIntoViewOptions
518
}
619

20+
// Indicates the default behaviour for the first option when the list is shown.
21+
export type FirstOptionSelectionMode = 'none' | 'active' | 'selected'
22+
723
export default class Combobox {
824
isComposing: boolean
925
list: HTMLElement
@@ -13,18 +29,18 @@ export default class Combobox {
1329
inputHandler: (event: Event) => void
1430
ctrlBindings: boolean
1531
tabInsertsSuggestions: boolean
16-
defaultFirstOption: boolean
32+
firstOptionSelectionMode: FirstOptionSelectionMode
1733
scrollIntoViewOptions?: boolean | ScrollIntoViewOptions
1834

1935
constructor(
2036
input: HTMLTextAreaElement | HTMLInputElement,
2137
list: HTMLElement,
22-
{tabInsertsSuggestions, defaultFirstOption, scrollIntoViewOptions}: ComboboxSettings = {},
38+
{tabInsertsSuggestions, firstOptionSelectionMode, scrollIntoViewOptions}: ComboboxSettings = {},
2339
) {
2440
this.input = input
2541
this.list = list
2642
this.tabInsertsSuggestions = tabInsertsSuggestions ?? true
27-
this.defaultFirstOption = defaultFirstOption ?? false
43+
this.firstOptionSelectionMode = firstOptionSelectionMode ?? 'none'
2844
this.scrollIntoViewOptions = scrollIntoViewOptions ?? {block: 'nearest', inline: 'nearest'}
2945

3046
this.isComposing = false
@@ -77,10 +93,12 @@ export default class Combobox {
7793
}
7894

7995
indicateDefaultOption(): void {
80-
if (this.defaultFirstOption) {
96+
if (this.firstOptionSelectionMode === 'active') {
8197
Array.from(this.list.querySelectorAll<HTMLElement>('[role="option"]:not([aria-disabled="true"])'))
8298
.filter(visible)[0]
8399
?.setAttribute('data-combobox-option-default', 'true')
100+
} else if (this.firstOptionSelectionMode === 'selected') {
101+
this.navigate(1)
84102
}
85103
}
86104

@@ -123,7 +141,10 @@ export default class Combobox {
123141
for (const el of this.list.querySelectorAll('[aria-selected="true"]')) {
124142
el.removeAttribute('aria-selected')
125143
}
126-
this.indicateDefaultOption()
144+
145+
if (this.firstOptionSelectionMode === 'active') {
146+
this.indicateDefaultOption()
147+
}
127148
}
128149
}
129150

test/test.js

+62-2
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ describe('combobox-nav', function () {
242242
})
243243
})
244244

245-
describe('with defaulting to first option', function () {
245+
describe('with defaulting to the first option being active', function () {
246246
let input
247247
let list
248248
let options
@@ -263,7 +263,7 @@ describe('combobox-nav', function () {
263263
input = document.querySelector('input')
264264
list = document.querySelector('ul')
265265
options = document.querySelectorAll('[role=option]')
266-
combobox = new Combobox(input, list, {defaultFirstOption: true})
266+
combobox = new Combobox(input, list, {firstOptionSelectionMode: 'active'})
267267
combobox.start()
268268
})
269269

@@ -276,6 +276,7 @@ describe('combobox-nav', function () {
276276
it('indicates first option when started', () => {
277277
assert.equal(document.querySelector('[data-combobox-option-default]'), options[0])
278278
assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 1)
279+
assert.equal(list.children[0].getAttribute('aria-selected'), null)
279280
})
280281

281282
it('indicates first option when restarted', () => {
@@ -311,4 +312,63 @@ describe('combobox-nav', function () {
311312
})
312313
})
313314
})
315+
316+
describe('with defaulting to the first option being selected', function () {
317+
let input
318+
let list
319+
let combobox
320+
beforeEach(function () {
321+
document.body.innerHTML = `
322+
<input type="text">
323+
<ul role="listbox" id="list-id">
324+
<li id="baymax" role="option">Baymax</li>
325+
<li><del>BB-8</del></li>
326+
<li id="hubot" role="option">Hubot</li>
327+
<li id="r2-d2" role="option">R2-D2</li>
328+
<li id="johnny-5" hidden role="option">Johnny 5</li>
329+
<li id="wall-e" role="option" aria-disabled="true">Wall-E</li>
330+
<li><a href="#link" role="option" id="link">Link</a></li>
331+
</ul>
332+
`
333+
input = document.querySelector('input')
334+
list = document.querySelector('ul')
335+
combobox = new Combobox(input, list, {firstOptionSelectionMode: 'selected'})
336+
combobox.start()
337+
})
338+
339+
afterEach(function () {
340+
combobox.destroy()
341+
combobox = null
342+
document.body.innerHTML = ''
343+
})
344+
345+
it('focuses first option when started', () => {
346+
// Does not set the default attribute
347+
assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 0)
348+
// Item is correctly selected
349+
assert.equal(list.children[0].getAttribute('aria-selected'), 'true')
350+
})
351+
352+
it('indicates first option when restarted', () => {
353+
combobox.stop()
354+
combobox.start()
355+
assert.equal(list.children[0].getAttribute('aria-selected'), 'true')
356+
})
357+
358+
it('applies default option on Enter', () => {
359+
let commits = 0
360+
document.addEventListener('combobox-commit', () => commits++)
361+
362+
assert.equal(commits, 0)
363+
press(input, 'Enter')
364+
assert.equal(commits, 1)
365+
})
366+
367+
it('does not error when no options are visible', () => {
368+
assert.doesNotThrow(() => {
369+
document.getElementById('list-id').style.display = 'none'
370+
combobox.clearSelection()
371+
})
372+
})
373+
})
314374
})

0 commit comments

Comments
 (0)