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${optionTag}>
@@ -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}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
- ${tag}>
- `);
+ <${tag} label="age">
+ <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
+ <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
+ ${tag}>
+ `);
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}>
- <${optionTag} .choiceValue=${'b'}>B${optionTag}>
- <${optionTag} .choiceValue=${'c'}>C${optionTag}>
- ${tag}>
- `);
- 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}>
- <${optionTag} .choiceValue=${'far'}>Far${optionTag}>
- <${optionTag} .choiceValue=${'foo'}>Foo${optionTag}>
- ${tag}>
- `);
- 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}>
+ <${optionTag} .choiceValue=${'teal'}>Teal${optionTag}>
+ <${optionTag} .choiceValue=${'turquoise'}>Turquoise${optionTag}>
+ ${tag}>
+ `);
+ // @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}>
+ <${optionTag} .choiceValue=${'teal'}>Teal${optionTag}>
+ <${optionTag} .choiceValue=${'turquoise'}>Turquoise${optionTag}>
+ ${tag}>
+ `);
+ // @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}>
+ <${optionTag} .choiceValue=${'teal'}>Teal${optionTag}>
+ <${optionTag} .choiceValue=${'turquoise'}>Turquoise${optionTag}>
+ ${tag}>
+ `);
+ // @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}>
+ <${optionTag} .choiceValue=${'teal'}>Teal${optionTag}>
+ <${optionTag} .choiceValue=${'turquoise'}>turquoise${optionTag}>
+ ${tag}>
+ `);
+ // @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}>
+ <${optionTag} .choiceValue=${'teal'}>Teal${optionTag}>
+ <${optionTag} .choiceValue=${'turquoise'}>Turquoise${optionTag}>
+ ${tag}>
+ `);
+ // @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', () => {