From c28985b6c7890cd53a1a04ef6892cbb39ce62dd5 Mon Sep 17 00:00:00 2001 From: Andrew Leach Date: Thu, 22 Feb 2024 09:34:01 +0000 Subject: [PATCH 1/6] Add new option --- src/index.ts | 20 ++++++++++++----- test/test.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6a60a10..4aedb2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,12 @@ export type ComboboxSettings = { tabInsertsSuggestions?: boolean - defaultFirstOption?: boolean + firstOptionSelectionMode?: FirstOptionSelectionMode scrollIntoViewOptions?: boolean | ScrollIntoViewOptions } +// Indicates the default behaviour for the first option when the list is shown. +export type FirstOptionSelectionMode = 'none' | 'selected' | 'focused' + export default class Combobox { isComposing: boolean list: HTMLElement @@ -13,18 +16,18 @@ export default class Combobox { inputHandler: (event: Event) => void ctrlBindings: boolean tabInsertsSuggestions: boolean - defaultFirstOption: boolean + firstOptionSelectionMode: FirstOptionSelectionMode scrollIntoViewOptions?: boolean | ScrollIntoViewOptions constructor( input: HTMLTextAreaElement | HTMLInputElement, list: HTMLElement, - {tabInsertsSuggestions, defaultFirstOption, scrollIntoViewOptions}: ComboboxSettings = {}, + {tabInsertsSuggestions, firstOptionSelectionMode, scrollIntoViewOptions}: ComboboxSettings = {}, ) { this.input = input this.list = list this.tabInsertsSuggestions = tabInsertsSuggestions ?? true - this.defaultFirstOption = defaultFirstOption ?? false + this.firstOptionSelectionMode = firstOptionSelectionMode ?? 'none' this.scrollIntoViewOptions = scrollIntoViewOptions ?? {block: 'nearest', inline: 'nearest'} this.isComposing = false @@ -64,6 +67,7 @@ export default class Combobox { ;(this.input as HTMLElement).addEventListener('keydown', this.keyboardEventHandler) this.list.addEventListener('click', commitWithElement) this.indicateDefaultOption() + this.focusDefaultOptionIfNeeded() } stop(): void { @@ -77,13 +81,19 @@ export default class Combobox { } indicateDefaultOption(): void { - if (this.defaultFirstOption) { + if (this.firstOptionSelectionMode === 'selected') { Array.from(this.list.querySelectorAll('[role="option"]:not([aria-disabled="true"])')) .filter(visible)[0] ?.setAttribute('data-combobox-option-default', 'true') } } + focusDefaultOptionIfNeeded(): void { + if (this.firstOptionSelectionMode === 'focused') { + this.navigate(1) + } + } + navigate(indexDiff: -1 | 1 = 1): void { const focusEl = Array.from(this.list.querySelectorAll('[aria-selected="true"]')).filter(visible)[0] const els = Array.from(this.list.querySelectorAll('[role="option"]')).filter(visible) diff --git a/test/test.js b/test/test.js index 03a82c5..5f47f13 100644 --- a/test/test.js +++ b/test/test.js @@ -263,7 +263,7 @@ describe('combobox-nav', function () { input = document.querySelector('input') list = document.querySelector('ul') options = document.querySelectorAll('[role=option]') - combobox = new Combobox(input, list, {defaultFirstOption: true}) + combobox = new Combobox(input, list, {firstOptionSelectionMode: 'selected'}) combobox.start() }) @@ -276,6 +276,7 @@ describe('combobox-nav', function () { 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) + assert.equal(list.children[0].getAttribute('aria-selected'), null) }) it('indicates first option when restarted', () => { @@ -311,4 +312,63 @@ describe('combobox-nav', function () { }) }) }) + + describe('with defaulting to focusing the first option', function () { + let input + let list + let combobox + beforeEach(function () { + document.body.innerHTML = ` + +
    +
  • Baymax
  • +
  • BB-8
  • +
  • Hubot
  • +
  • R2-D2
  • + +
  • Wall-E
  • +
  • Link
  • +
+ ` + input = document.querySelector('input') + list = document.querySelector('ul') + combobox = new Combobox(input, list, {firstOptionSelectionMode: 'focused'}) + combobox.start() + }) + + afterEach(function () { + combobox.destroy() + combobox = null + document.body.innerHTML = '' + }) + + it('focuses first option when started', () => { + // Does not set the default attribute + assert.equal(document.querySelectorAll('[data-combobox-option-default]').length, 0) + // Item is correctly selected + assert.equal(list.children[0].getAttribute('aria-selected'), 'true') + }) + + it('indicates first option when restarted', () => { + combobox.stop() + combobox.start() + assert.equal(list.children[0].getAttribute('aria-selected'), 'true') + }) + + 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('does not error when no options are visible', () => { + assert.doesNotThrow(() => { + document.getElementById('list-id').style.display = 'none' + combobox.clearSelection() + }) + }) + }) }) From 3995d9d201e2178b907255cca2edb1f67a771ddd Mon Sep 17 00:00:00 2001 From: Andrew Leach Date: Thu, 22 Feb 2024 15:30:36 +0000 Subject: [PATCH 2/6] PR feedback --- src/index.ts | 6 +++--- test/test.js | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4aedb2a..ffecb05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ export type ComboboxSettings = { } // Indicates the default behaviour for the first option when the list is shown. -export type FirstOptionSelectionMode = 'none' | 'selected' | 'focused' +export type FirstOptionSelectionMode = 'none' | 'active' | 'selected' export default class Combobox { isComposing: boolean @@ -81,7 +81,7 @@ export default class Combobox { } indicateDefaultOption(): void { - if (this.firstOptionSelectionMode === 'selected') { + if (this.firstOptionSelectionMode === 'active') { Array.from(this.list.querySelectorAll('[role="option"]:not([aria-disabled="true"])')) .filter(visible)[0] ?.setAttribute('data-combobox-option-default', 'true') @@ -89,7 +89,7 @@ export default class Combobox { } focusDefaultOptionIfNeeded(): void { - if (this.firstOptionSelectionMode === 'focused') { + if (this.firstOptionSelectionMode === 'selected') { this.navigate(1) } } diff --git a/test/test.js b/test/test.js index 5f47f13..3368cce 100644 --- a/test/test.js +++ b/test/test.js @@ -242,7 +242,7 @@ describe('combobox-nav', function () { }) }) - describe('with defaulting to first option', function () { + describe('with defaulting to the first option being active', function () { let input let list let options @@ -263,7 +263,7 @@ describe('combobox-nav', function () { input = document.querySelector('input') list = document.querySelector('ul') options = document.querySelectorAll('[role=option]') - combobox = new Combobox(input, list, {firstOptionSelectionMode: 'selected'}) + combobox = new Combobox(input, list, {firstOptionSelectionMode: 'active'}) combobox.start() }) @@ -313,7 +313,7 @@ describe('combobox-nav', function () { }) }) - describe('with defaulting to focusing the first option', function () { + describe('with defaulting to the first option being selected', function () { let input let list let combobox @@ -332,7 +332,7 @@ describe('combobox-nav', function () { ` input = document.querySelector('input') list = document.querySelector('ul') - combobox = new Combobox(input, list, {firstOptionSelectionMode: 'focused'}) + combobox = new Combobox(input, list, {firstOptionSelectionMode: 'selected'}) combobox.start() }) From 1ef750e2aab5c4603aca7a0a3bb9e8f0dd9c0900 Mon Sep 17 00:00:00 2001 From: Andrew Leach Date: Thu, 22 Feb 2024 16:06:46 +0000 Subject: [PATCH 3/6] More PR feedback --- src/index.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index ffecb05..5d31a04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,7 +67,6 @@ export default class Combobox { ;(this.input as HTMLElement).addEventListener('keydown', this.keyboardEventHandler) this.list.addEventListener('click', commitWithElement) this.indicateDefaultOption() - this.focusDefaultOptionIfNeeded() } stop(): void { @@ -85,11 +84,7 @@ export default class Combobox { Array.from(this.list.querySelectorAll('[role="option"]:not([aria-disabled="true"])')) .filter(visible)[0] ?.setAttribute('data-combobox-option-default', 'true') - } - } - - focusDefaultOptionIfNeeded(): void { - if (this.firstOptionSelectionMode === 'selected') { + } else if (this.firstOptionSelectionMode === 'selected') { this.navigate(1) } } @@ -133,7 +128,10 @@ export default class Combobox { for (const el of this.list.querySelectorAll('[aria-selected="true"]')) { el.removeAttribute('aria-selected') } - this.indicateDefaultOption() + + if (this.firstOptionSelectionMode === 'active') { + this.indicateDefaultOption() + } } } From 5f5c1b016bc45adda3f61ebb0a046e0507e76a02 Mon Sep 17 00:00:00 2001 From: Andrew Leach Date: Thu, 22 Feb 2024 17:25:30 +0100 Subject: [PATCH 4/6] Update documentation Co-authored-by: Ian Sanders --- src/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/index.ts b/src/index.ts index 5d31a04..5164a8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,18 @@ export type ComboboxSettings = { tabInsertsSuggestions?: boolean + /** + * Indicates the default behaviour for the first option when the list is shown: + * + * - `'none'`: Don't auto-select the first option at all. + * - `'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. + * - `'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. + */ firstOptionSelectionMode?: FirstOptionSelectionMode scrollIntoViewOptions?: boolean | ScrollIntoViewOptions } From 895654a0b47364339128d9acc126ed811efb5ff5 Mon Sep 17 00:00:00 2001 From: Andrew Leach Date: Thu, 22 Feb 2024 16:30:36 +0000 Subject: [PATCH 5/6] Update docs --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c6f6ca3..b98542f 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,11 @@ const combobox = new Combobox(input, list, {tabInsertsSuggestions: true}) These settings are available: - `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. -- `defaultFirstOption: boolean = false` - If no options are selected and the user presses Enter, 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. +- `firstOptionSelectionMode: FirstOptionSelectionMode = 'none'` - This option dictates the default behaviour when no options have been selected yet and the user presses Enter. The following options of `FirstOptionSelectionMode` will do the following: + - `'none'`: Don't auto-select the first option at all. + - `'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. + > **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 + - `'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. - `scrollIntoViewOptions?: boolean | ScrollIntoViewOptions = undefined` - When 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. From 5c6bd763e9a72d78f95f059db609729fb8c31aa7 Mon Sep 17 00:00:00 2001 From: Andrew Leach Date: Thu, 22 Feb 2024 16:31:14 +0000 Subject: [PATCH 6/6] Update wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b98542f..4f5a5a1 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ const combobox = new Combobox(input, list, {tabInsertsSuggestions: true}) These settings are available: - `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. -- `firstOptionSelectionMode: FirstOptionSelectionMode = 'none'` - This option dictates the default behaviour when no options have been selected yet and the user presses Enter. The following options of `FirstOptionSelectionMode` will do the following: +- `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: - `'none'`: Don't auto-select the first option at all. - `'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. > **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