feat(listbox): add type ahead option
This commit is contained in:
parent
4129786909
commit
a28686ee72
8 changed files with 253 additions and 47 deletions
6
.changeset/fresh-paws-run.md
Normal file
6
.changeset/fresh-paws-run.md
Normal 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
|
||||
|
|
@ -138,7 +138,7 @@ export const disabledRotateNavigation = () => html`
|
|||
<lion-option .choiceValue=${'Beets'}>Beets</lion-option>
|
||||
<lion-option .choiceValue=${'Bell pepper'}>Bell pepper</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=${'Carrot'}>Carrot</lion-option>
|
||||
</lion-listbox>
|
||||
|
|
|
|||
|
|
@ -331,7 +331,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
* @protected
|
||||
*/
|
||||
this._listboxReceivesNoFocus = true;
|
||||
|
||||
/**
|
||||
* @configure ListboxMixin
|
||||
* @protected
|
||||
*/
|
||||
this._noTypeAhead = true;
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ const fixture = /** @type {(arg: TemplateResult) => Promise<LionListbox>} */ (_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,8 +396,7 @@ 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}>
|
||||
|
|
@ -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 () => {
|
||||
|
||||
describe('Typeahead', () => {
|
||||
it('activates 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} opened id="color" name="color" label="Favorite color">
|
||||
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
|
||||
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
|
||||
<${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}>
|
||||
</${tag}>
|
||||
`);
|
||||
expect(el.modelValue).to.equal('a');
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' }));
|
||||
expect(el.modelValue).to.equal('c');
|
||||
// @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.skip('selects a value with multiple [character] keys', async () => {
|
||||
|
||||
it('activates 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} opened id="color" name="color" label="Favorite color">
|
||||
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
|
||||
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
|
||||
<${optionTag} .choiceValue=${'turquoise'}>Turquoise</${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');
|
||||
// @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);
|
||||
|
|
|
|||
|
|
@ -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<T extends Constructor<LitElement>>(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue