Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ $ npm install @github/combobox-nav
```html
<label>
Robot
<input id="robot-input" type="text">
<input id="robot-input" type="text" />
</label>
<ul role="listbox" id="list-id" hidden>
<li id="baymax" role="option">Baymax</li>
<li><del>BB-8</del></li><!-- `role=option` needs to be present for item to be selectable -->
<li><del>BB-8</del></li>
<!-- `role=option` needs to be present for item to be selectable -->
<li id="hubot" role="option">Hubot</li>
<li id="r2-d2" role="option">R2-D2</li>
</ul>
Expand Down Expand Up @@ -59,27 +60,29 @@ A bubbling `combobox-commit` event is fired on the list element when an option i
For example, autocomplete when an option is selected:

```js
list.addEventListener('combobox-commit', function(event) {
list.addEventListener('combobox-commit', function (event) {
console.log('Element selected: ', event.target)
})
```

**⚠ Note:** When using `<label>` + `<input>` as options, please listen on `change` instead of `combobox-commit`.
> **Note** When using `<label>` + `<input>` as options, please listen on `change` instead of `combobox-commit`.

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.

## Settings

For advanced configuration, the constructor takes an optional third argument. This is a settings object with the following setting:

- `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.

For example:
For advanced configuration, the constructor takes an optional third argument. For example:

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

These settings are available:

- `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.
- `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.
> **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.

## Development

```
Expand Down
24 changes: 20 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type ComboboxSettings = {
tabInsertsSuggestions?: boolean
defaultFirstOption?: boolean
}

export default class Combobox {
Expand All @@ -11,15 +12,17 @@ export default class Combobox {
inputHandler: (event: Event) => void
ctrlBindings: boolean
tabInsertsSuggestions: boolean
defaultFirstOption: boolean

constructor(
input: HTMLTextAreaElement | HTMLInputElement,
list: HTMLElement,
{tabInsertsSuggestions}: ComboboxSettings = {}
{tabInsertsSuggestions, defaultFirstOption}: ComboboxSettings = {}
) {
this.input = input
this.list = list
this.tabInsertsSuggestions = tabInsertsSuggestions ?? true
this.defaultFirstOption = defaultFirstOption ?? false

this.isComposing = false

Expand Down Expand Up @@ -57,6 +60,7 @@ export default class Combobox {
this.input.addEventListener('input', this.inputHandler)
;(this.input as HTMLElement).addEventListener('keydown', this.keyboardEventHandler)
this.list.addEventListener('click', commitWithElement)
this.indicateDefaultOption()
}

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

indicateDefaultOption(): void {
if (this.defaultFirstOption) {
Array.from(this.list.querySelectorAll<HTMLElement>('[role="option"]:not([aria-disabled="true"])'))
.filter(visible)[0]
.setAttribute('data-combobox-option-default', 'true')
}
}

navigate(indexDiff: -1 | 1 = 1): void {
const focusEl = Array.from(this.list.querySelectorAll<HTMLElement>('[aria-selected="true"]')).filter(visible)[0]
const els = Array.from(this.list.querySelectorAll<HTMLElement>('[role="option"]')).filter(visible)
Expand All @@ -88,22 +100,26 @@ export default class Combobox {

const target = els[indexOfItem]
if (!target) return

for (const el of els) {
el.removeAttribute('data-combobox-option-default')

if (target === el) {
this.input.setAttribute('aria-activedescendant', target.id)
target.setAttribute('aria-selected', 'true')
scrollTo(this.list, target)
} else {
el.setAttribute('aria-selected', 'false')
el.removeAttribute('aria-selected')
}
}
}

clearSelection(): void {
this.input.removeAttribute('aria-activedescendant')
for (const el of this.list.querySelectorAll('[aria-selected="true"]')) {
el.setAttribute('aria-selected', 'false')
el.removeAttribute('aria-selected')
}
this.indicateDefaultOption()
}
}

Expand Down Expand Up @@ -161,7 +177,7 @@ function commitWithElement(event: MouseEvent) {
}

function commit(input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement): boolean {
const target = list.querySelector<HTMLElement>('[aria-selected="true"]')
const target = list.querySelector<HTMLElement>('[aria-selected="true"], [data-combobox-option-default="true"]')
if (!target) return false
if (target.getAttribute('aria-disabled') === 'true') return true
target.click()
Expand Down
67 changes: 65 additions & 2 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('combobox-nav', function () {
assert(!list.querySelector('[aria-selected=true]'), 'Nothing should be selected')

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

assert(!input.hasAttribute('role'))
assert(!input.hasAttribute('aria-expanded'))
Expand Down Expand Up @@ -204,7 +204,7 @@ describe('combobox-nav', function () {

combobox.clearSelection()

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

Expand All @@ -226,4 +226,67 @@ describe('combobox-nav', function () {
assert.equal(list.scrollTop, options[1].offsetTop)
})
})

describe('with defaulting to first option', function () {
let input
let list
let options
let combobox
beforeEach(function () {
document.body.innerHTML = `
<input type="text">
<ul role="listbox" id="list-id">
<li id="baymax" role="option">Baymax</li>
<li><del>BB-8</del></li>
<li id="hubot" role="option">Hubot</li>
<li id="r2-d2" role="option">R2-D2</li>
<li id="johnny-5" hidden role="option">Johnny 5</li>
<li id="wall-e" role="option" aria-disabled="true">Wall-E</li>
<li><a href="#link" role="option" id="link">Link</a></li>
</ul>
`
input = document.querySelector('input')
list = document.querySelector('ul')
options = document.querySelectorAll('[role=option]')
combobox = new Combobox(input, list, {defaultFirstOption: true})
combobox.start()
})

afterEach(function () {
combobox.destroy()
combobox = null
document.body.innerHTML = ''
})

it('indicates first option when started', () => {
assert.equal(document.querySelector('[data-combobox-option-default]'), options[0])
assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 1)
})

it('indicates first option when restarted', () => {
combobox.stop()
combobox.start()
assert.equal(document.querySelector('[data-combobox-option-default]'), options[0])
})

it('applies default option on Enter', () => {
let commits = 0
document.addEventListener('combobox-commit', () => commits++)

assert.equal(commits, 0)
press(input, 'Enter')
assert.equal(commits, 1)
})

it('clears default indication when navigating', () => {
combobox.navigate(1)
assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 0)
})

it('resets default indication when selection cleared', () => {
combobox.navigate(1)
combobox.clearSelection()
assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 1)
})
})
})