├── .gitattributes
├── .gitignore
├── CODEOWNERS
├── .eslintignore
├── .travis.yml
├── test
├── .eslintrc.json
└── test.js
├── tsconfig.json
├── .eslintrc.json
├── karma.config.cjs
├── .github
└── workflows
│ ├── nodejs.yml
│ └── publish.yml
├── LICENSE
├── package.json
├── examples
└── index.html
├── README.md
└── src
└── index.ts
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @github/primer-reviewers
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 | prettier.config.js
3 | karma.config.js
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | os: osx
2 | language: node_js
3 | sudo: required
4 | node_js:
5 | - "node"
6 | addons:
7 | chrome: stable
8 | cache:
9 | directories:
10 | - node_modules
11 |
--------------------------------------------------------------------------------
/test/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "espree",
3 | "parserOptions": {
4 | "ecmaVersion": 8
5 | },
6 | "env": {
7 | "mocha": true
8 | },
9 | "globals": {
10 | "assert": true
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "esnext",
4 | "target": "es2017",
5 | "strict": true,
6 | "declaration": true,
7 | "outDir": "dist",
8 | "removeComments": true
9 | },
10 | "files": [
11 | "src/index.ts"
12 | ]
13 | }
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["github"],
3 | "extends": [
4 | "plugin:github/browser",
5 | "plugin:github/recommended",
6 | "plugin:github/typescript"
7 | ],
8 | "globals": {
9 | "Combobox": "readable"
10 | },
11 | "overrides": [
12 | {
13 | "files": "test/**/*.js",
14 | "rules": {
15 | "github/no-inner-html": "off",
16 | "github/unescaped-html-literal": "off",
17 | "import/extensions": "off",
18 | "import/no-unresolved": "off"
19 | }
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/karma.config.cjs:
--------------------------------------------------------------------------------
1 | process.env.CHROME_BIN = require('chromium').path
2 |
3 | module.exports = function(config) {
4 | config.set({
5 | frameworks: ['mocha', 'chai'],
6 | files: [
7 | {pattern: 'dist/index.js', type: 'module'},
8 | {pattern: 'test/test.js', type: 'module'}
9 | ],
10 | reporters: ['mocha'],
11 | port: 9876,
12 | colors: true,
13 | logLevel: config.LOG_INFO,
14 | browsers: ['ChromeHeadless'],
15 | autoWatch: false,
16 | singleRun: true,
17 | concurrency: Infinity
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Node CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | build:
13 | runs-on: ${{ matrix.os }}
14 |
15 | strategy:
16 | fail-fast: false
17 | matrix:
18 | os: [ubuntu-22.04, windows-latest, macos-latest]
19 |
20 | steps:
21 | - uses: actions/checkout@v1
22 | - name: Use Node.js 18.x
23 | uses: actions/setup-node@v1
24 | with:
25 | node-version: 18.x
26 | - name: npm install, build, and test
27 | run: |
28 | npm install
29 | npm run build --if-present
30 | npm test
31 | env:
32 | CI: true
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 GitHub
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@github/combobox-nav",
3 | "description": "Attach combobox navigation behavior to an input.",
4 | "version": "2.1.5",
5 | "main": "dist/index.js",
6 | "module": "dist/index.js",
7 | "type": "module",
8 | "types": "dist/index.d.ts",
9 | "license": "MIT",
10 | "repository": "github/combobox-nav",
11 | "prettier": "@github/prettier-config",
12 | "files": [
13 | "dist"
14 | ],
15 | "scripts": {
16 | "clean": "rm -rf dist",
17 | "lint": "eslint .",
18 | "prebuild": "npm run clean && npm run lint && mkdir dist",
19 | "build": "tsc",
20 | "test": "karma start karma.config.cjs",
21 | "pretest": "npm run build",
22 | "prepublishOnly": "npm run build"
23 | },
24 | "devDependencies": {
25 | "@github/prettier-config": "^0.0.6",
26 | "chai": "^4.3.9",
27 | "chromium": "^3.0.3",
28 | "eslint": "^8.50.0",
29 | "eslint-plugin-github": "^4.10.1",
30 | "karma": "^6.4.2",
31 | "karma-chai": "^0.1.0",
32 | "karma-chrome-launcher": "^3.2.0",
33 | "karma-mocha": "^2.0.1",
34 | "karma-mocha-reporter": "^2.2.5",
35 | "mocha": "^10.2.0",
36 | "typescript": "^5.2.2"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | publish-npm:
9 | name: Publish to npm
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: read
13 | id-token: write
14 | steps:
15 | - uses: actions/checkout@v3
16 | - uses: actions/setup-node@v3
17 | with:
18 | node-version: 18
19 | registry-url: https://registry.npmjs.org/
20 | cache: npm
21 | - run: npm ci
22 | - run: npm test
23 | - run: npm version ${TAG_NAME} --git-tag-version=false
24 | env:
25 | TAG_NAME: ${{ github.event.release.tag_name }}
26 | # Install latest version of npm for publishing with provenance
27 | - run: npm install -g npm
28 | - run: npm whoami; npm --ignore-scripts publish --provenance --access public
29 | env:
30 | NODE_AUTH_TOKEN: ${{secrets.npm_token}}
31 | publish-github:
32 | name: Publish to GitHub Packages
33 | runs-on: ubuntu-latest
34 | permissions:
35 | contents: read
36 | packages: write
37 | steps:
38 | - uses: actions/checkout@v3
39 | - uses: actions/setup-node@v3
40 | with:
41 | node-version: 18
42 | registry-url: https://npm.pkg.github.com
43 | cache: npm
44 | scope: '@github'
45 | - run: npm ci
46 | - run: npm test
47 | - run: npm version ${TAG_NAME} --git-tag-version=false
48 | env:
49 | TAG_NAME: ${{ github.event.release.tag_name }}
50 | - run: npm --ignore-scripts publish --access public
51 | env:
52 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | combobox-nav demo
6 |
7 |
8 |
9 |
22 |
23 |
24 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Combobox Navigation
2 |
3 | Attach [combobox navigation behavior (ARIA 1.2)](https://www.w3.org/TR/wai-aria-1.2/#combobox) to ` `.
4 |
5 | ## Installation
6 |
7 | ```
8 | $ npm install @github/combobox-nav
9 | ```
10 |
11 | ## Usage
12 |
13 | ### HTML
14 |
15 | ```html
16 |
17 | Robot
18 |
19 |
20 |
21 | Baymax
22 | BB-8
23 |
24 | Hubot
25 | R2-D2
26 |
27 | ```
28 |
29 | Markup requirements:
30 |
31 | - Each option needs to have `role="option"` and a unique `id`
32 | - The list should have `role="listbox"`
33 |
34 | ### JS
35 |
36 | ```js
37 | import Combobox from '@github/combobox-nav'
38 | const input = document.querySelector('#robot-input')
39 | const list = document.querySelector('#list-id')
40 |
41 | // install combobox pattern on a given input and listbox
42 | const combobox = new Combobox(input, list)
43 | // when options appear, start intercepting keyboard events for navigation
44 | combobox.start()
45 | // when options disappear, stop intercepting keyboard events for navigation
46 | combobox.stop()
47 |
48 | // move selection to the nth+1 item in the list
49 | combobox.navigate(1)
50 | // reset selection
51 | combobox.clearSelection()
52 | // uninstall combobox pattern from the input
53 | combobox.destroy()
54 | ```
55 |
56 | ## Events
57 |
58 | A bubbling `combobox-commit` event is fired on the list element when an option is selected via keyboard or click.
59 |
60 | For example, autocomplete when an option is selected:
61 |
62 | ```js
63 | list.addEventListener('combobox-commit', function (event) {
64 | console.log('Element selected: ', event.target)
65 | })
66 | ```
67 |
68 | > **Note** When using `` + ` ` as options, please listen on `change` instead of `combobox-commit`.
69 |
70 | When a label is clicked on, `click` event is fired from both `` 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.
71 |
72 | A bubbling `combobox-select` event is fired on the list element when an option is selected but not yet committed.
73 |
74 | For example, autocomplete when an option is selected but not yet committed:
75 |
76 | ```js
77 | list.addEventListener('combobox-select', function (event) {
78 | console.log('Element selected : ', event.target)
79 | })
80 | ```
81 |
82 | ## Settings
83 |
84 | For advanced configuration, the constructor takes an optional third argument. For example:
85 |
86 | ```js
87 | const combobox = new Combobox(input, list, {tabInsertsSuggestions: true})
88 | ```
89 |
90 | These settings are available:
91 |
92 | - `tabInsertsSuggestions: boolean = true` - Control whether the highlighted suggestion is inserted when Tab is pressed (Enter 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 | - `firstOptionSelectionMode: FirstOptionSelectionMode = 'none'` - This option dictates the default behaviour when no options have been selected yet and the user presses Enter . 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.
98 | - `scrollIntoViewOptions?: boolean | ScrollIntoViewOptions = undefined` - When
99 | 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.
100 |
101 | [Element.scrollIntoView]: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView
102 | [ScrollIntoViewOptions]: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#sect1
103 |
104 |
105 | ## Development
106 |
107 | ```
108 | npm install
109 | npm test
110 | ```
111 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export type ComboboxSettings = {
2 | tabInsertsSuggestions?: 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
17 | scrollIntoViewOptions?: boolean | ScrollIntoViewOptions
18 | }
19 |
20 | // Indicates the default behaviour for the first option when the list is shown.
21 | export type FirstOptionSelectionMode = 'none' | 'active' | 'selected'
22 |
23 | export default class Combobox {
24 | isComposing: boolean
25 | list: HTMLElement
26 | input: HTMLTextAreaElement | HTMLInputElement
27 | keyboardEventHandler: (event: KeyboardEvent) => void
28 | compositionEventHandler: (event: Event) => void
29 | inputHandler: (event: Event) => void
30 | ctrlBindings: boolean
31 | tabInsertsSuggestions: boolean
32 | firstOptionSelectionMode: FirstOptionSelectionMode
33 | scrollIntoViewOptions?: boolean | ScrollIntoViewOptions
34 |
35 | constructor(
36 | input: HTMLTextAreaElement | HTMLInputElement,
37 | list: HTMLElement,
38 | {tabInsertsSuggestions, firstOptionSelectionMode, scrollIntoViewOptions}: ComboboxSettings = {},
39 | ) {
40 | this.input = input
41 | this.list = list
42 | this.tabInsertsSuggestions = tabInsertsSuggestions ?? true
43 | this.firstOptionSelectionMode = firstOptionSelectionMode ?? 'none'
44 | this.scrollIntoViewOptions = scrollIntoViewOptions ?? {block: 'nearest', inline: 'nearest'}
45 |
46 | this.isComposing = false
47 |
48 | if (!list.id) {
49 | list.id = `combobox-${Math.random().toString().slice(2, 6)}`
50 | }
51 |
52 | this.ctrlBindings = !!navigator.userAgent.match(/Macintosh/)
53 |
54 | this.keyboardEventHandler = event => keyboardBindings(event, this)
55 | this.compositionEventHandler = event => trackComposition(event, this)
56 | this.inputHandler = this.clearSelection.bind(this)
57 | input.setAttribute('role', 'combobox')
58 | input.setAttribute('aria-controls', list.id)
59 | input.setAttribute('aria-expanded', 'false')
60 | input.setAttribute('aria-autocomplete', 'list')
61 | input.setAttribute('aria-haspopup', 'listbox')
62 | }
63 |
64 | destroy() {
65 | this.clearSelection()
66 | this.stop()
67 |
68 | this.input.removeAttribute('role')
69 | this.input.removeAttribute('aria-controls')
70 | this.input.removeAttribute('aria-expanded')
71 | this.input.removeAttribute('aria-autocomplete')
72 | this.input.removeAttribute('aria-haspopup')
73 | }
74 |
75 | start(): void {
76 | this.input.setAttribute('aria-expanded', 'true')
77 | this.input.addEventListener('compositionstart', this.compositionEventHandler)
78 | this.input.addEventListener('compositionend', this.compositionEventHandler)
79 | this.input.addEventListener('input', this.inputHandler)
80 | ;(this.input as HTMLElement).addEventListener('keydown', this.keyboardEventHandler)
81 | this.list.addEventListener('click', commitWithElement)
82 | this.resetSelection()
83 | }
84 |
85 | stop(): void {
86 | this.clearSelection()
87 | this.input.setAttribute('aria-expanded', 'false')
88 | this.input.removeEventListener('compositionstart', this.compositionEventHandler)
89 | this.input.removeEventListener('compositionend', this.compositionEventHandler)
90 | this.input.removeEventListener('input', this.inputHandler)
91 | ;(this.input as HTMLElement).removeEventListener('keydown', this.keyboardEventHandler)
92 | this.list.removeEventListener('click', commitWithElement)
93 | }
94 |
95 | indicateDefaultOption(): void {
96 | if (this.firstOptionSelectionMode === 'active') {
97 | Array.from(this.list.querySelectorAll('[role="option"]:not([aria-disabled="true"])'))
98 | .filter(visible)[0]
99 | ?.setAttribute('data-combobox-option-default', 'true')
100 | } else if (this.firstOptionSelectionMode === 'selected') {
101 | this.navigate(1)
102 | }
103 | }
104 |
105 | navigate(indexDiff: -1 | 1 = 1): void {
106 | const focusEl = Array.from(this.list.querySelectorAll('[aria-selected="true"]')).filter(visible)[0]
107 | const els = Array.from(this.list.querySelectorAll('[role="option"]')).filter(visible)
108 | const focusIndex = els.indexOf(focusEl)
109 |
110 | if ((focusIndex === els.length - 1 && indexDiff === 1) || (focusIndex === 0 && indexDiff === -1)) {
111 | this.clearSelection()
112 | this.input.focus()
113 | return
114 | }
115 |
116 | let indexOfItem = indexDiff === 1 ? 0 : els.length - 1
117 | if (focusEl && focusIndex >= 0) {
118 | const newIndex = focusIndex + indexDiff
119 | if (newIndex >= 0 && newIndex < els.length) indexOfItem = newIndex
120 | }
121 |
122 | const target = els[indexOfItem]
123 | if (!target) return
124 |
125 | for (const el of els) {
126 | el.removeAttribute('data-combobox-option-default')
127 |
128 | if (target === el) {
129 | this.input.setAttribute('aria-activedescendant', target.id)
130 | target.setAttribute('aria-selected', 'true')
131 | fireSelectEvent(target)
132 | target.scrollIntoView(this.scrollIntoViewOptions)
133 | } else {
134 | el.removeAttribute('aria-selected')
135 | }
136 | }
137 | }
138 |
139 | clearSelection(): void {
140 | this.input.removeAttribute('aria-activedescendant')
141 | for (const el of this.list.querySelectorAll('[aria-selected="true"], [data-combobox-option-default="true"]')) {
142 | el.removeAttribute('aria-selected')
143 | el.removeAttribute('data-combobox-option-default')
144 | }
145 | }
146 |
147 | resetSelection(): void {
148 | this.clearSelection()
149 | this.indicateDefaultOption()
150 | }
151 | }
152 |
153 | function keyboardBindings(event: KeyboardEvent, combobox: Combobox) {
154 | if (event.shiftKey || event.metaKey || event.altKey) return
155 | if (!combobox.ctrlBindings && event.ctrlKey) return
156 | if (combobox.isComposing) return
157 |
158 | switch (event.key) {
159 | case 'Enter':
160 | if (commit(combobox.input, combobox.list)) {
161 | event.preventDefault()
162 | }
163 | break
164 | case 'Tab':
165 | if (combobox.tabInsertsSuggestions && commit(combobox.input, combobox.list)) {
166 | event.preventDefault()
167 | }
168 | break
169 | case 'Escape':
170 | combobox.clearSelection()
171 | break
172 | case 'ArrowDown':
173 | combobox.navigate(1)
174 | event.preventDefault()
175 | break
176 | case 'ArrowUp':
177 | combobox.navigate(-1)
178 | event.preventDefault()
179 | break
180 | case 'n':
181 | if (combobox.ctrlBindings && event.ctrlKey) {
182 | combobox.navigate(1)
183 | event.preventDefault()
184 | }
185 | break
186 | case 'p':
187 | if (combobox.ctrlBindings && event.ctrlKey) {
188 | combobox.navigate(-1)
189 | event.preventDefault()
190 | }
191 | break
192 | default:
193 | if (event.ctrlKey) break
194 | combobox.resetSelection()
195 | }
196 | }
197 |
198 | function commitWithElement(event: MouseEvent) {
199 | if (!(event.target instanceof Element)) return
200 | const target = event.target.closest('[role="option"]')
201 | if (!target) return
202 | if (target.getAttribute('aria-disabled') === 'true') return
203 | fireCommitEvent(target, {event})
204 | }
205 |
206 | function commit(input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement): boolean {
207 | const target = list.querySelector('[aria-selected="true"], [data-combobox-option-default="true"]')
208 | if (!target) return false
209 | if (target.getAttribute('aria-disabled') === 'true') return true
210 | target.click()
211 | return true
212 | }
213 |
214 | function fireCommitEvent(target: Element, detail?: Record): void {
215 | target.dispatchEvent(new CustomEvent('combobox-commit', {bubbles: true, detail}))
216 | }
217 |
218 | function fireSelectEvent(target: Element): void {
219 | target.dispatchEvent(new Event('combobox-select', {bubbles: true}))
220 | }
221 |
222 | function visible(el: HTMLElement): boolean {
223 | return (
224 | !el.hidden &&
225 | !(el instanceof HTMLInputElement && el.type === 'hidden') &&
226 | (el.offsetWidth > 0 || el.offsetHeight > 0)
227 | )
228 | }
229 |
230 | function trackComposition(event: Event, combobox: Combobox): void {
231 | combobox.isComposing = event.type === 'compositionstart'
232 |
233 | const list = document.getElementById(combobox.input.getAttribute('aria-controls') || '')
234 | if (!list) return
235 |
236 | combobox.clearSelection()
237 | }
238 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | import Combobox from '../dist/index.js'
2 |
3 | const ctrlBindings = !!navigator.userAgent.match(/Macintosh/)
4 |
5 | function press(input, key, ctrlKey) {
6 | input.dispatchEvent(new KeyboardEvent('keydown', {key, ctrlKey}))
7 | }
8 |
9 | function click(element) {
10 | element.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))
11 | }
12 |
13 | describe('combobox-nav', function () {
14 | describe('with API', function () {
15 | let input
16 | let list
17 | beforeEach(function () {
18 | document.body.innerHTML = `
19 |
20 |
21 | Baymax
22 | BB-8
23 | Hubot
24 | R2-D2
25 |
26 | `
27 | input = document.querySelector('input')
28 | list = document.querySelector('ul')
29 | })
30 |
31 | afterEach(function () {
32 | document.body.innerHTML = ''
33 | })
34 |
35 | it('installs, starts, navigates, stops, and uninstalls', function () {
36 | const combobox = new Combobox(input, list)
37 | assert.equal(input.getAttribute('role'), 'combobox')
38 | assert.equal(input.getAttribute('aria-expanded'), 'false')
39 | assert.equal(input.getAttribute('aria-controls'), 'list-id')
40 | assert.equal(input.getAttribute('aria-autocomplete'), 'list')
41 | assert.equal(input.getAttribute('aria-haspopup'), 'listbox')
42 |
43 | combobox.start()
44 | assert.equal(input.getAttribute('aria-expanded'), 'true')
45 |
46 | press(input, 'ArrowDown')
47 | assert.equal(list.children[0].getAttribute('aria-selected'), 'true')
48 | combobox.navigate(1)
49 | assert.equal(list.children[2].getAttribute('aria-selected'), 'true')
50 |
51 | combobox.stop()
52 | press(input, 'ArrowDown')
53 | assert(!input.hasAttribute('aria-activedescendant'), 'Nothing should be selected')
54 | assert(!list.querySelector('[aria-selected=true]'), 'Nothing should be selected')
55 |
56 | combobox.destroy()
57 | assert.equal(list.children[2].getAttribute('aria-selected'), null)
58 |
59 | assert(!input.hasAttribute('role'))
60 | assert(!input.hasAttribute('aria-expanded'))
61 | assert(!input.hasAttribute('aria-controls'))
62 | assert(!input.hasAttribute('aria-autocomplete'))
63 | assert(!input.hasAttribute('aria-haspopup'))
64 | })
65 | })
66 |
67 | describe('with default setup', function () {
68 | let input
69 | let list
70 | let options
71 | let combobox
72 | beforeEach(function () {
73 | document.body.innerHTML = `
74 |
75 |
76 | Baymax
77 | BB-8
78 | Hubot
79 | R2-D2
80 | Johnny 5
81 | Wall-E
82 | Link
83 |
84 | `
85 | input = document.querySelector('input')
86 | list = document.querySelector('ul')
87 | options = document.querySelectorAll('[role=option]')
88 | combobox = new Combobox(input, list)
89 | combobox.start()
90 | })
91 |
92 | afterEach(function () {
93 | combobox.destroy()
94 | combobox = null
95 | document.body.innerHTML = ''
96 | })
97 |
98 | it('updates attributes on keyboard events', function () {
99 | const expectedTargets = []
100 |
101 | document.addEventListener('combobox-commit', function ({target}) {
102 | expectedTargets.push(target.id)
103 | })
104 |
105 | press(input, 'ArrowDown')
106 | assert.equal(options[0].getAttribute('aria-selected'), 'true')
107 | assert.equal(input.getAttribute('aria-activedescendant'), 'baymax')
108 |
109 | press(input, 'ArrowDown')
110 | assert.equal(options[1].getAttribute('aria-selected'), 'true')
111 | assert.equal(input.getAttribute('aria-activedescendant'), 'hubot')
112 |
113 | press(input, 'Enter')
114 |
115 | ctrlBindings ? press(input, 'n', true) : press(input, 'ArrowDown')
116 | assert.equal(options[2].getAttribute('aria-selected'), 'true')
117 | assert.equal(input.getAttribute('aria-activedescendant'), 'r2-d2')
118 |
119 | ctrlBindings ? press(input, 'n', true) : press(input, 'ArrowDown')
120 | assert.equal(options[4].getAttribute('aria-selected'), 'true')
121 | assert.equal(input.getAttribute('aria-activedescendant'), 'wall-e')
122 | press(input, 'Enter')
123 | click(document.getElementById('wall-e'))
124 |
125 | ctrlBindings ? press(input, 'n', true) : press(input, 'ArrowDown')
126 | assert.equal(options[5].getAttribute('aria-selected'), 'true')
127 | assert.equal(input.getAttribute('aria-activedescendant'), 'link')
128 |
129 | press(input, 'ArrowDown')
130 | assert(!list.querySelector('[aria-selected=true]'), 'Nothing should be selected')
131 | assert(!input.hasAttribute('aria-activedescendant'), 'Nothing should be selected')
132 |
133 | press(input, 'ArrowDown')
134 | assert.equal(options[0].getAttribute('aria-selected'), 'true')
135 | assert.equal(input.getAttribute('aria-activedescendant'), 'baymax')
136 |
137 | ctrlBindings ? press(input, 'p', true) : press(input, 'ArrowUp')
138 | assert(!list.querySelector('[aria-selected=true]'), 'Nothing should be selected')
139 | assert(!input.hasAttribute('aria-activedescendant'), 'Nothing should be selected')
140 |
141 | press(input, 'ArrowUp')
142 | assert.equal(options[5].getAttribute('aria-selected'), 'true')
143 | assert.equal(input.getAttribute('aria-activedescendant'), 'link')
144 |
145 | press(input, 'Enter')
146 | assert.equal(expectedTargets.length, 2)
147 | assert.equal(expectedTargets[0], 'hubot')
148 | assert.equal(expectedTargets[1], 'link')
149 | })
150 |
151 | it('fires commit events on click', function () {
152 | const expectedTargets = []
153 |
154 | document.addEventListener('combobox-commit', function ({target}) {
155 | expectedTargets.push(target.id)
156 | })
157 |
158 | click(document.getElementById('hubot'))
159 | click(document.querySelectorAll('li')[1])
160 | click(document.getElementById('baymax'))
161 |
162 | assert.equal(expectedTargets.length, 2)
163 | assert.equal(expectedTargets[0], 'hubot')
164 | assert.equal(expectedTargets[1], 'baymax')
165 | })
166 |
167 | it('fires select events on navigating', function () {
168 | const expectedTargets = []
169 |
170 | document.addEventListener('combobox-select', function ({target}) {
171 | expectedTargets.push(target.id)
172 | })
173 |
174 | press(input, 'ArrowDown')
175 | press(input, 'ArrowDown')
176 |
177 | assert.equal(expectedTargets.length, 2)
178 | assert.equal(expectedTargets[0], 'baymax')
179 | assert.equal(expectedTargets[1], 'hubot')
180 | })
181 |
182 | it('clear selection on input operation', function () {
183 | press(input, 'ArrowDown')
184 | assert.equal(options[0].getAttribute('aria-selected'), 'true')
185 | assert.equal(input.getAttribute('aria-activedescendant'), 'baymax')
186 |
187 | press(input, 'ArrowLeft')
188 | assert(!list.querySelector('[aria-selected=true]'), 'Nothing should be selected')
189 | assert(!input.hasAttribute('aria-activedescendant'), 'Nothing should be selected')
190 |
191 | press(input, 'ArrowDown')
192 | assert.equal(options[0].getAttribute('aria-selected'), 'true')
193 | assert.equal(input.getAttribute('aria-activedescendant'), 'baymax')
194 |
195 | press(input, 'Control', true)
196 | assert.equal(options[0].getAttribute('aria-selected'), 'true', 'Selection stays on modifier keydown')
197 | assert.equal(input.getAttribute('aria-activedescendant'), 'baymax', 'Selection stays on modifier keydown')
198 |
199 | press(input, 'Backspace')
200 | assert(!list.querySelector('[aria-selected=true]'), 'Nothing should be selected')
201 | assert(!input.hasAttribute('aria-activedescendant'), 'Nothing should be selected')
202 | })
203 |
204 | it('fires event and follows the link on click', function () {
205 | let eventFired = false
206 | document.addEventListener('combobox-commit', function () {
207 | eventFired = true
208 | })
209 |
210 | click(document.querySelectorAll('[role=option]')[5])
211 | assert(eventFired)
212 | assert.equal(window.location.hash, '#link')
213 | })
214 |
215 | it('clears aria-activedescendant and sets aria-selected=false when cleared', function () {
216 | press(input, 'ArrowDown')
217 | assert.equal(options[0].getAttribute('aria-selected'), 'true')
218 | assert.equal(input.getAttribute('aria-activedescendant'), 'baymax')
219 |
220 | combobox.clearSelection()
221 |
222 | assert.equal(options[0].getAttribute('aria-selected'), null)
223 | assert.equal(input.hasAttribute('aria-activedescendant'), false)
224 | })
225 |
226 | it('scrolls when the selected item is not in view', function () {
227 | list.style.overflow = 'auto'
228 | list.style.height = '18px'
229 | list.style.position = 'relative'
230 | assert.equal(list.scrollTop, 0)
231 |
232 | press(input, 'ArrowDown')
233 | assert.equal(options[0].getAttribute('aria-selected'), 'true')
234 | assert.equal(input.getAttribute('aria-activedescendant'), 'baymax')
235 | assert.equal(list.scrollTop, 0)
236 |
237 | press(input, 'ArrowDown')
238 |
239 | assert.equal(options[1].getAttribute('aria-selected'), 'true')
240 | assert.equal(input.getAttribute('aria-activedescendant'), 'hubot')
241 | assert.equal(list.scrollTop, options[1].offsetTop)
242 | })
243 | })
244 |
245 | describe('with defaulting to the first option being active', function () {
246 | let input
247 | let list
248 | let options
249 | let combobox
250 | beforeEach(function () {
251 | document.body.innerHTML = `
252 |
253 |
254 | Baymax
255 | BB-8
256 | Hubot
257 | R2-D2
258 | Johnny 5
259 | Wall-E
260 | Link
261 |
262 | `
263 | input = document.querySelector('input')
264 | list = document.querySelector('ul')
265 | options = document.querySelectorAll('[role=option]')
266 | combobox = new Combobox(input, list, {firstOptionSelectionMode: 'active'})
267 | combobox.start()
268 | })
269 |
270 | afterEach(function () {
271 | combobox.destroy()
272 | combobox = null
273 | document.body.innerHTML = ''
274 | })
275 |
276 | it('indicates first option when started', () => {
277 | assert.equal(document.querySelector('[data-combobox-option-default]'), options[0])
278 | assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 1)
279 | assert.equal(list.children[0].getAttribute('aria-selected'), null)
280 | })
281 |
282 | it('indicates first option when restarted', () => {
283 | combobox.stop()
284 | combobox.start()
285 | assert.equal(document.querySelector('[data-combobox-option-default]'), options[0])
286 | })
287 |
288 | it('first item remains active when typing', () => {
289 | const text = 'R2-D2'
290 | for (let i = 0; i < text.length; i++) {
291 | press(input, text[i])
292 | }
293 |
294 | assert.equal(document.querySelector('[data-combobox-option-default]'), options[0])
295 | })
296 |
297 | it('applies default option on Enter', () => {
298 | let commits = 0
299 | document.addEventListener('combobox-commit', () => commits++)
300 |
301 | assert.equal(commits, 0)
302 | press(input, 'Enter')
303 | assert.equal(commits, 1)
304 | })
305 |
306 | it('clears default indication when navigating', () => {
307 | combobox.navigate(1)
308 | assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 0)
309 | })
310 |
311 | it('resets default indication when selection reset', () => {
312 | combobox.navigate(1)
313 | combobox.resetSelection()
314 | assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 1)
315 | })
316 |
317 | it('removes default indication when selection cleared', () => {
318 | combobox.navigate(1)
319 | combobox.clearSelection()
320 | assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 0)
321 | })
322 |
323 | it('does not error when no options are visible', () => {
324 | assert.doesNotThrow(() => {
325 | document.getElementById('list-id').style.display = 'none'
326 | combobox.clearSelection()
327 | })
328 | })
329 | })
330 |
331 | describe('with defaulting to the first option being selected', function () {
332 | let input
333 | let list
334 | let combobox
335 | beforeEach(function () {
336 | document.body.innerHTML = `
337 |
338 |
339 | Baymax
340 | BB-8
341 | Hubot
342 | R2-D2
343 | Link
344 |
345 | `
346 | input = document.querySelector('input')
347 | list = document.querySelector('ul')
348 | combobox = new Combobox(input, list, {firstOptionSelectionMode: 'selected'})
349 | combobox.start()
350 | })
351 |
352 | afterEach(function () {
353 | combobox.destroy()
354 | combobox = null
355 | document.body.innerHTML = ''
356 | })
357 |
358 | it('focuses first option when started', () => {
359 | // Does not set the default attribute
360 | assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 0)
361 | // Item is correctly selected
362 | assert.equal(list.children[0].getAttribute('aria-selected'), 'true')
363 | })
364 |
365 | it('first item remains selected when typing', () => {
366 | const text = 'R2-D2'
367 | for (let i = 0; i < text.length; i++) {
368 | press(input, text[i])
369 | }
370 |
371 | assert.equal(list.children[0].getAttribute('aria-selected'), 'true')
372 | })
373 |
374 | it('pressing key down off the last item will have no items selected', () => {
375 | // Get all visible options in the list
376 | const options = document.querySelectorAll('[role=option]:not([aria-hidden=true])')
377 | // Key press down for each item and ensure the next is selected
378 | for (let i = 0; i < options.length; i++) {
379 | if (i > 0) {
380 | assert.equal(options[i - 1].getAttribute('aria-selected'), null)
381 | }
382 |
383 | assert.equal(options[i].getAttribute('aria-selected'), 'true')
384 | press(input, 'ArrowDown')
385 | }
386 |
387 | const selected = document.querySelectorAll('[aria-selected]')
388 | assert.equal(selected.length, 0)
389 | })
390 |
391 | it('indicates first option when restarted', () => {
392 | combobox.stop()
393 | combobox.start()
394 | assert.equal(list.children[0].getAttribute('aria-selected'), 'true')
395 | })
396 |
397 | it('applies default option on Enter', () => {
398 | let commits = 0
399 | document.addEventListener('combobox-commit', () => commits++)
400 |
401 | assert.equal(commits, 0)
402 | press(input, 'Enter')
403 | assert.equal(commits, 1)
404 | })
405 |
406 | it('does not error when no options are visible', () => {
407 | assert.doesNotThrow(() => {
408 | document.getElementById('list-id').style.display = 'none'
409 | combobox.clearSelection()
410 | })
411 | })
412 | })
413 | })
414 |
--------------------------------------------------------------------------------