feat(listbox): add type ahead option

This commit is contained in:
gvangeest 2022-04-28 17:08:35 +02:00 committed by Thijs Louisse
parent 4129786909
commit a28686ee72
8 changed files with 253 additions and 47 deletions

View file

@ -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

View file

@ -138,7 +138,7 @@ export const disabledRotateNavigation = () => html`
<lion-option .choiceValue=${'Beets'}>Beets</lion-option> <lion-option .choiceValue=${'Beets'}>Beets</lion-option>
<lion-option .choiceValue=${'Bell pepper'}>Bell pepper</lion-option> <lion-option .choiceValue=${'Bell pepper'}>Bell pepper</lion-option>
<lion-option .choiceValue=${'Broccoli'}>Broccoli</lion-option> <lion-option .choiceValue=${'Broccoli'}>Broccoli</lion-option>
<lion-option .choiceValue=${'Brussel sprout'} disabled>Brussels sprout</lion-option> <lion-option .choiceValue=${'Brussels sprout'} disabled>Brussels sprout</lion-option>
<lion-option .choiceValue=${'Cabbage'}>Cabbage</lion-option> <lion-option .choiceValue=${'Cabbage'}>Cabbage</lion-option>
<lion-option .choiceValue=${'Carrot'}>Carrot</lion-option> <lion-option .choiceValue=${'Carrot'}>Carrot</lion-option>
</lion-listbox> </lion-listbox>

View file

@ -331,7 +331,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @protected * @protected
*/ */
this._listboxReceivesNoFocus = true; this._listboxReceivesNoFocus = true;
/**
* @configure ListboxMixin
* @protected
*/
this._noTypeAhead = true;
/** /**
* @private * @private
*/ */

View file

@ -81,6 +81,9 @@ const ListboxMixinImplementation = superclass =>
reflect: true, reflect: true,
attribute: 'has-no-default-selected', 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 * See: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus
*/ */
this.selectionFollowsFocus = false; 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} * @type {number | null}
* @protected * @protected
@ -327,6 +339,11 @@ const ListboxMixinImplementation = superclass =>
* @private * @private
*/ */
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this); this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
/**
* @type {string[]}
* @private
*/
this.__typedChars = [];
} }
connectedCallback() { connectedCallback() {
@ -466,6 +483,39 @@ const ListboxMixinImplementation = superclass =>
this.resetInteractionState(); 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 * @override ChoiceGroupMixin: in the select disabled options are still going to a possible
* value, for example when prefilling or programmatically setting it. * value, for example when prefilling or programmatically setting it.
@ -621,7 +671,12 @@ const ListboxMixinImplementation = superclass =>
ev.preventDefault(); ev.preventDefault();
this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0); this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0);
break; break;
/* no default */ default:
if (!this._noTypeAhead) {
this._handleTypeAhead(ev, {
setAsChecked: this.selectionFollowsFocus && !this.multipleChoice,
});
}
} }
const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']; const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'];

View file

@ -26,10 +26,11 @@ const fixture = /** @type {(arg: TemplateResult) => Promise<LionListbox>} */ (_f
/** /**
* @param {HTMLElement} el * @param {HTMLElement} el
* @param {string} key * @param {string} key
* @param {string} code
*/ */
function mimicKeyPress(el, key) { function mimicKeyPress(el, key, code = '') {
el.dispatchEvent(new KeyboardEvent('keydown', { key })); el.dispatchEvent(new KeyboardEvent('keydown', { key, code }));
el.dispatchEvent(new KeyboardEvent('keyup', { key })); el.dispatchEvent(new KeyboardEvent('keyup', { key, code }));
} }
/** /**
@ -381,8 +382,7 @@ export function runListboxMixinSuite(customConfig = {}) {
}); });
describe('Accessibility', () => { describe('Accessibility', () => {
// TODO: enable when native button is not a child anymore it('[axe]: is accessible when opened', async () => {
it.skip('[axe]: is accessible when opened', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} label="age" opened> <${tag} label="age" opened>
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
@ -396,8 +396,7 @@ export function runListboxMixinSuite(customConfig = {}) {
}); });
// NB: regular listbox is always 'opened', but needed for combobox and select-rich // NB: regular listbox is always 'opened', but needed for combobox and select-rich
// TODO: enable when native button is not a child anymore it('[axe]: is accessible when closed', async () => {
it.skip('[axe]: is accessible when closed', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} label="age"> <${tag} label="age">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
@ -442,7 +441,6 @@ export function runListboxMixinSuite(customConfig = {}) {
await el.updateComplete; await el.updateComplete;
expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('first'); expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('first');
mimicKeyPress(_activeDescendantOwnerNode, 'ArrowDown'); mimicKeyPress(_activeDescendantOwnerNode, 'ArrowDown');
// _activeDescendantOwnerNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
await el.updateComplete; await el.updateComplete;
expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('second'); expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('second');
}); });
@ -601,7 +599,6 @@ export function runListboxMixinSuite(customConfig = {}) {
// Normalize // Normalize
el.activeIndex = 0; el.activeIndex = 0;
const options = el.formElements; const options = el.formElements;
// mimicKeyPress(listbox, 'ArrowUp');
mimicKeyPress(_listboxNode, 'ArrowUp'); mimicKeyPress(_listboxNode, 'ArrowUp');
@ -609,7 +606,6 @@ export function runListboxMixinSuite(customConfig = {}) {
expect(options[1].active).to.be.false; expect(options[1].active).to.be.false;
expect(options[2].active).to.be.false; expect(options[2].active).to.be.false;
el.activeIndex = 2; el.activeIndex = 2;
// mimicKeyPress(listbox, 'ArrowDown');
mimicKeyPress(_listboxNode, 'ArrowDown'); mimicKeyPress(_listboxNode, 'ArrowDown');
expect(options[0].active).to.be.false; 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: 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 () => { describe('Typeahead', () => {
it('activates a value with single [character] key', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} opened> <${tag} opened id="color" name="color" label="Favorite color">
<${optionTag} .choiceValue=${'a'}>A</${optionTag}> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'b'}>B</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
<${optionTag} .choiceValue=${'c'}>C</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}>
</${tag}> </${tag}>
`); `);
expect(el.modelValue).to.equal('a'); // @ts-expect-error [allow-protected-in-tests]
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' })); if (el._noTypeAhead) {
expect(el.modelValue).to.equal('c'); 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.skip('selects a value with multiple [character] keys', async () => {
it('activates a value with multiple [character] keys', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} opened> <${tag} opened id="color" name="color" label="Favorite color">
<${optionTag} .choiceValue=${'bar'}>Bar</${optionTag}> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'far'}>Far</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
<${optionTag} .choiceValue=${'foo'}>Foo</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}>
</${tag}> </${tag}>
`); `);
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F' })); // @ts-expect-error [allow-protected-in-tests]
expect(el.modelValue).to.equal('far'); if (el._noTypeAhead) {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' })); return;
expect(el.modelValue).to.equal('foo'); }
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 () => { it('navigates to first and last option with [Home] and [End] keys', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} opened> <${tag} opened>
@ -1021,7 +1123,7 @@ export function runListboxMixinSuite(customConfig = {}) {
const { _listboxNode } = getListboxMembers(el); const { _listboxNode } = getListboxMembers(el);
const options = el.formElements; 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.activeIndex = 0;
el.checkedIndex = 0; el.checkedIndex = 0;
expect(el.activeIndex).to.equal(0); expect(el.activeIndex).to.equal(0);

View file

@ -51,10 +51,14 @@ export declare class ListboxHost {
protected _listboxReceivesNoFocus: boolean; protected _listboxReceivesNoFocus: boolean;
protected _noTypeAhead: boolean;
protected _uncheckChildren(): void; protected _uncheckChildren(): void;
private __setupListboxNode(): void; private __setupListboxNode(): void;
protected _handleTypeAhead(ev: KeyboardEvent, { setAsChecked: boolean }): void;
protected _getPreviousEnabledOption(currentIndex: number, offset?: number): number; protected _getPreviousEnabledOption(currentIndex: number, offset?: number): number;
protected _getNextEnabledOption(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 get _activeDescendantOwnerNode(): HTMLElement;
protected _onListboxContentChanged(): void; protected _onListboxContentChanged(): void;
private __pendingTypeAheadTimeout: number | undefined;
} }
export declare function ListboxImplementation<T extends Constructor<LitElement>>( export declare function ListboxImplementation<T extends Constructor<LitElement>>(

View file

@ -489,7 +489,10 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this.opened = true; this.opened = true;
} }
break; 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 // Tab can only be caught in keydown
this.opened = false; this.opened = false;
break; break;
/* no default */
case 'Escape': case 'Escape':
this.opened = false; this.opened = false;
this.__blockListShowDuringTransition(); this.__blockListShowDuringTransition();

View file

@ -10,6 +10,16 @@ import '@lion/select-rich/define';
* @typedef {import('@lion/listbox').LionOption} LionOption * @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 * @param {LionSelectRich} lionSelectEl
*/ */
@ -142,6 +152,27 @@ describe('lion-select-rich interactions', () => {
expect(el.checkedIndex).to.equal(0); expect(el.checkedIndex).to.equal(0);
expectOnlyGivenOneOptionToBeChecked(options, 0); expectOnlyGivenOneOptionToBeChecked(options, 0);
}); });
it('checkes a value with [character] keys while listbox unopened', async () => {
const el = /** @type {LionSelectRich} */ (
await fixture(html`
<lion-select-rich interaction-mode="windows/linux">
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
<lion-option .choiceValue=${'turquoise'}>Turquoise</lion-option>
</lion-options>
</lion-select-rich>
`)
);
// @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', () => { describe('Disabled', () => {