From a28686ee72533cd38edbc20abfebe2adc11ab8b8 Mon Sep 17 00:00:00 2001 From: gvangeest Date: Thu, 28 Apr 2022 17:08:35 +0200 Subject: [PATCH] feat(listbox): add type ahead option --- .changeset/fresh-paws-run.md | 6 + docs/components/listbox/use-cases.md | 2 +- packages/combobox/src/LionCombobox.js | 6 +- packages/listbox/src/ListboxMixin.js | 59 +++++- .../listbox/test-suites/ListboxMixin.suite.js | 184 ++++++++++++++---- packages/listbox/types/ListboxMixinTypes.d.ts | 6 + packages/select-rich/src/LionSelectRich.js | 6 +- .../test/lion-select-rich-interaction.test.js | 31 +++ 8 files changed, 253 insertions(+), 47 deletions(-) create mode 100644 .changeset/fresh-paws-run.md diff --git a/.changeset/fresh-paws-run.md b/.changeset/fresh-paws-run.md new file mode 100644 index 000000000..66ea79b00 --- /dev/null +++ b/.changeset/fresh-paws-run.md @@ -0,0 +1,6 @@ +--- +'@lion/listbox': minor +'@lion/select-rich': minor +--- + +Add TypeAhead, so with typing characters you will set an option with matching value active/checked diff --git a/docs/components/listbox/use-cases.md b/docs/components/listbox/use-cases.md index 49198262c..03bde9342 100644 --- a/docs/components/listbox/use-cases.md +++ b/docs/components/listbox/use-cases.md @@ -138,7 +138,7 @@ export const disabledRotateNavigation = () => html` Beets Bell pepper Broccoli - Brussels sprout + Brussels sprout Cabbage Carrot diff --git a/packages/combobox/src/LionCombobox.js b/packages/combobox/src/LionCombobox.js index 60b333271..84ad0c951 100644 --- a/packages/combobox/src/LionCombobox.js +++ b/packages/combobox/src/LionCombobox.js @@ -331,7 +331,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) { * @protected */ this._listboxReceivesNoFocus = true; - + /** + * @configure ListboxMixin + * @protected + */ + this._noTypeAhead = true; /** * @private */ diff --git a/packages/listbox/src/ListboxMixin.js b/packages/listbox/src/ListboxMixin.js index c43d5cc6f..6548f0bd0 100644 --- a/packages/listbox/src/ListboxMixin.js +++ b/packages/listbox/src/ListboxMixin.js @@ -81,6 +81,9 @@ const ListboxMixinImplementation = superclass => reflect: true, attribute: 'has-no-default-selected', }, + _noTypeAhead: { + type: Boolean, + }, }; } @@ -270,7 +273,16 @@ const ListboxMixinImplementation = superclass => * See: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus */ this.selectionFollowsFocus = false; - + /** + * When false, a user can type on which the focus will jump to the matching option + */ + this._noTypeAhead = false; + /** + * The pending char sequence that will set active list item + * @type {number} + * @protected + */ + this._typeAheadTimeout = 1000; /** * @type {number | null} * @protected @@ -327,6 +339,11 @@ const ListboxMixinImplementation = superclass => * @private */ this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this); + /** + * @type {string[]} + * @private + */ + this.__typedChars = []; } connectedCallback() { @@ -466,6 +483,39 @@ const ListboxMixinImplementation = superclass => this.resetInteractionState(); } + /** + * @param {KeyboardEvent} ev + * @param {{setAsChecked:boolean}} options + * @protected + */ + _handleTypeAhead(ev, { setAsChecked }) { + const { key, code } = ev; + + if (code.startsWith('Key') || code.startsWith('Digit') || code.startsWith('Numpad')) { + ev.preventDefault(); + this.__typedChars.push(key); + const chars = this.__typedChars.join(''); + const matchedItemIndex = + // TODO: consider making this condition overridable for Subclassers by extracting it into protected method + this.formElements.findIndex(el => el.modelValue.value.toLowerCase().startsWith(chars)); + if (matchedItemIndex >= 0) { + if (setAsChecked) { + this.setCheckedIndex(matchedItemIndex); + } + this.activeIndex = matchedItemIndex; + } + if (this.__pendingTypeAheadTimeout) { + // Prevent that pending timeouts 'intersect' with new 'typeahead sessions' + // @ts-ignore + window.clearTimeout(this.__pendingTypeAheadTimeout); + } + this.__pendingTypeAheadTimeout = setTimeout(() => { + // schedule a timeout to reset __typedChars + this.__typedChars = []; + }, this._typeAheadTimeout); + } + } + /** * @override ChoiceGroupMixin: in the select disabled options are still going to a possible * value, for example when prefilling or programmatically setting it. @@ -621,7 +671,12 @@ const ListboxMixinImplementation = superclass => ev.preventDefault(); this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0); break; - /* no default */ + default: + if (!this._noTypeAhead) { + this._handleTypeAhead(ev, { + setAsChecked: this.selectionFollowsFocus && !this.multipleChoice, + }); + } } const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']; diff --git a/packages/listbox/test-suites/ListboxMixin.suite.js b/packages/listbox/test-suites/ListboxMixin.suite.js index eaa56ad9b..9de8a33ab 100644 --- a/packages/listbox/test-suites/ListboxMixin.suite.js +++ b/packages/listbox/test-suites/ListboxMixin.suite.js @@ -26,10 +26,11 @@ const fixture = /** @type {(arg: TemplateResult) => Promise} */ (_f /** * @param {HTMLElement} el * @param {string} key + * @param {string} code */ -function mimicKeyPress(el, key) { - el.dispatchEvent(new KeyboardEvent('keydown', { key })); - el.dispatchEvent(new KeyboardEvent('keyup', { key })); +function mimicKeyPress(el, key, code = '') { + el.dispatchEvent(new KeyboardEvent('keydown', { key, code })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, code })); } /** @@ -381,8 +382,7 @@ export function runListboxMixinSuite(customConfig = {}) { }); describe('Accessibility', () => { - // TODO: enable when native button is not a child anymore - it.skip('[axe]: is accessible when opened', async () => { + it('[axe]: is accessible when opened', async () => { const el = await fixture(html` <${tag} label="age" opened> <${optionTag} .choiceValue=${10}>Item 1 @@ -396,14 +396,13 @@ export function runListboxMixinSuite(customConfig = {}) { }); // NB: regular listbox is always 'opened', but needed for combobox and select-rich - // TODO: enable when native button is not a child anymore - it.skip('[axe]: is accessible when closed', async () => { + it('[axe]: is accessible when closed', async () => { const el = await fixture(html` - <${tag} label="age"> - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - `); + <${tag} label="age"> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); await expect(el).to.be.accessible(); }); @@ -442,7 +441,6 @@ export function runListboxMixinSuite(customConfig = {}) { await el.updateComplete; expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('first'); mimicKeyPress(_activeDescendantOwnerNode, 'ArrowDown'); - // _activeDescendantOwnerNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); await el.updateComplete; expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('second'); }); @@ -601,7 +599,6 @@ export function runListboxMixinSuite(customConfig = {}) { // Normalize el.activeIndex = 0; const options = el.formElements; - // mimicKeyPress(listbox, 'ArrowUp'); mimicKeyPress(_listboxNode, 'ArrowUp'); @@ -609,7 +606,6 @@ export function runListboxMixinSuite(customConfig = {}) { expect(options[1].active).to.be.false; expect(options[2].active).to.be.false; el.activeIndex = 2; - // mimicKeyPress(listbox, 'ArrowDown'); mimicKeyPress(_listboxNode, 'ArrowDown'); expect(options[0].active).to.be.false; @@ -709,32 +705,138 @@ export function runListboxMixinSuite(customConfig = {}) { }); }); // TODO: add key combinations like shift+home/ctrl+A etc etc. - // TODO: nice to have. Get from menu impl. - it.skip('selects a value with single [character] key', async () => { - const el = await fixture(html` - <${tag} opened> - <${optionTag} .choiceValue=${'a'}>A - <${optionTag} .choiceValue=${'b'}>B - <${optionTag} .choiceValue=${'c'}>C - - `); - expect(el.modelValue).to.equal('a'); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' })); - expect(el.modelValue).to.equal('c'); - }); - it.skip('selects a value with multiple [character] keys', async () => { - const el = await fixture(html` - <${tag} opened> - <${optionTag} .choiceValue=${'bar'}>Bar - <${optionTag} .choiceValue=${'far'}>Far - <${optionTag} .choiceValue=${'foo'}>Foo - - `); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F' })); - expect(el.modelValue).to.equal('far'); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' })); - expect(el.modelValue).to.equal('foo'); + + describe('Typeahead', () => { + it('activates a value with single [character] key', async () => { + const el = await fixture(html` + <${tag} opened id="color" name="color" label="Favorite color"> + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'teal'}>Teal + <${optionTag} .choiceValue=${'turquoise'}>Turquoise + + `); + // @ts-expect-error [allow-protected-in-tests] + if (el._noTypeAhead) { + return; + } + + const { _listboxNode } = getListboxMembers(el); + + // Normalize start values between listbox, select and combobox and test interaction below + el.activeIndex = 0; + + mimicKeyPress(_listboxNode, 't', 'KeyT'); + // await aTimeout(0); + expect(el.activeIndex).to.equal(1); + }); + + it('activates a value with multiple [character] keys', async () => { + const el = await fixture(html` + <${tag} opened id="color" name="color" label="Favorite color"> + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'teal'}>Teal + <${optionTag} .choiceValue=${'turquoise'}>Turquoise + + `); + // @ts-expect-error [allow-protected-in-tests] + if (el._noTypeAhead) { + return; + } + + const { _listboxNode } = getListboxMembers(el); + + // Normalize start values between listbox, select and combobox and test interaction below + el.activeIndex = 0; + + mimicKeyPress(_listboxNode, 't', 'KeyT'); + expect(el.activeIndex).to.equal(1); + + mimicKeyPress(_listboxNode, 'u', 'KeyU'); + expect(el.activeIndex).to.equal(2); + }); + + it('selects a value with [character] keys and selectionFollowsFocus', async () => { + const el = await fixture(html` + <${tag} opened id="color" name="color" label="Favorite color" selection-follows-focus> + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'teal'}>Teal + <${optionTag} .choiceValue=${'turquoise'}>Turquoise + + `); + // @ts-expect-error [allow-protected-in-tests] + if (el._noTypeAhead) { + return; + } + + const { _listboxNode } = getListboxMembers(el); + + // Normalize start values between listbox, select and combobox and test interaction below + el.checkedIndex = 0; + + mimicKeyPress(_listboxNode, 't', 'KeyT'); + expect(el.checkedIndex).to.equal(1); + + mimicKeyPress(_listboxNode, 'u', 'KeyU'); + expect(el.checkedIndex).to.equal(2); + }); + + it('clears typedChars after _typeAheadTimeout', async () => { + const el = await fixture(html` + <${tag} opened id="color" name="color" label="Favorite color"> + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'teal'}>Teal + <${optionTag} .choiceValue=${'turquoise'}>turquoise + + `); + // @ts-expect-error [allow-protected-in-tests] + if (el._noTypeAhead) { + return; + } + + const clock = sinon.useFakeTimers(); + const { _listboxNode } = getListboxMembers(el); + + mimicKeyPress(_listboxNode, 't', 'KeyT'); + // @ts-ignore [allow-private] in test + expect(el.__typedChars).to.deep.equal(['t']); + + mimicKeyPress(_listboxNode, 'u', 'KeyU'); + // @ts-ignore [allow-private] in test + expect(el.__typedChars).to.deep.equal(['t', 'u']); + + clock.tick(1000); + // @ts-ignore [allow-private] in test + expect(el.__typedChars).to.deep.equal([]); + + clock.restore(); + }); + + it('clears scheduled timeouts', async () => { + const el = await fixture(html` + <${tag} opened id="color" name="color" label="Favorite color"> + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'teal'}>Teal + <${optionTag} .choiceValue=${'turquoise'}>Turquoise + + `); + // @ts-expect-error [allow-protected-in-tests] + if (el._noTypeAhead) { + return; + } + + const { _listboxNode } = getListboxMembers(el); + + // Normalize start values between listbox, select and combobox and test interaction below + el.activeIndex = 0; + mimicKeyPress(_listboxNode, 't', 'KeyT'); + // @ts-expect-error [allow-private-in-tests] + const pendingClear = el.__pendingTypeAheadTimeout; + const clearTimeoutSpy = sinon.spy(window, 'clearTimeout'); + mimicKeyPress(_listboxNode, 'u', 'KeyU'); + expect(clearTimeoutSpy.args[0][0]).to.equal(pendingClear); + }); }); + it('navigates to first and last option with [Home] and [End] keys', async () => { const el = await fixture(html` <${tag} opened> @@ -1021,7 +1123,7 @@ export function runListboxMixinSuite(customConfig = {}) { const { _listboxNode } = getListboxMembers(el); const options = el.formElements; - // Normalize start values between listbox, slect and combobox and test interaction below + // Normalize start values between listbox, select and combobox and test interaction below el.activeIndex = 0; el.checkedIndex = 0; expect(el.activeIndex).to.equal(0); diff --git a/packages/listbox/types/ListboxMixinTypes.d.ts b/packages/listbox/types/ListboxMixinTypes.d.ts index 700929be4..71765f69b 100644 --- a/packages/listbox/types/ListboxMixinTypes.d.ts +++ b/packages/listbox/types/ListboxMixinTypes.d.ts @@ -51,10 +51,14 @@ export declare class ListboxHost { protected _listboxReceivesNoFocus: boolean; + protected _noTypeAhead: boolean; + protected _uncheckChildren(): void; private __setupListboxNode(): void; + protected _handleTypeAhead(ev: KeyboardEvent, { setAsChecked: boolean }): void; + protected _getPreviousEnabledOption(currentIndex: number, offset?: number): number; protected _getNextEnabledOption(currentIndex: number, offset?: number): number; @@ -78,6 +82,8 @@ export declare class ListboxHost { protected get _activeDescendantOwnerNode(): HTMLElement; protected _onListboxContentChanged(): void; + + private __pendingTypeAheadTimeout: number | undefined; } export declare function ListboxImplementation>( diff --git a/packages/select-rich/src/LionSelectRich.js b/packages/select-rich/src/LionSelectRich.js index 856002808..061c3ba9f 100644 --- a/packages/select-rich/src/LionSelectRich.js +++ b/packages/select-rich/src/LionSelectRich.js @@ -489,7 +489,10 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L this.opened = true; } break; - /* no default */ + default: + if (!this._noTypeAhead) { + this._handleTypeAhead(ev, { setAsChecked: true }); + } } } @@ -514,7 +517,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L // Tab can only be caught in keydown this.opened = false; break; - /* no default */ case 'Escape': this.opened = false; this.__blockListShowDuringTransition(); diff --git a/packages/select-rich/test/lion-select-rich-interaction.test.js b/packages/select-rich/test/lion-select-rich-interaction.test.js index 4ba4f70e2..61310a22f 100644 --- a/packages/select-rich/test/lion-select-rich-interaction.test.js +++ b/packages/select-rich/test/lion-select-rich-interaction.test.js @@ -10,6 +10,16 @@ import '@lion/select-rich/define'; * @typedef {import('@lion/listbox').LionOption} LionOption */ +/** + * @param {HTMLElement} el + * @param {string} key + * @param {string} code + */ +function mimicKeyPress(el, key, code = '') { + el.dispatchEvent(new KeyboardEvent('keydown', { key, code })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, code })); +} + /** * @param {LionSelectRich} lionSelectEl */ @@ -142,6 +152,27 @@ describe('lion-select-rich interactions', () => { expect(el.checkedIndex).to.equal(0); expectOnlyGivenOneOptionToBeChecked(options, 0); }); + + it('checkes a value with [character] keys while listbox unopened', async () => { + const el = /** @type {LionSelectRich} */ ( + await fixture(html` + + + Red + Teal + Turquoise + + + `) + ); + + // @ts-ignore [allow-private] in test + mimicKeyPress(el, 't', 'KeyT'); + expect(el.checkedIndex).to.equal(1); + + mimicKeyPress(el, 'u', 'KeyU'); + expect(el.checkedIndex).to.equal(2); + }); }); describe('Disabled', () => {