fix(combobox): when multiple choice reset all listbox options on select (#2332)

* fix(combobox): when multiple choice reset all listbox options on select

* Apply suggestions from code review

Co-authored-by: Thijs Louisse <thijs.louisse@ing.com>

* chore: clear options also on click

* chore: adopt code to show list when only when showAllOnEmpty is true

---------

Co-authored-by: Thijs Louisse <thijs.louisse@ing.com>
This commit is contained in:
gerjanvangeest 2024-08-27 10:48:12 +02:00 committed by GitHub
parent 5fa385e923
commit 02a9427a7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 174 additions and 18 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[combobox] reset listbox options on click/enter for multiple-choice

View file

@ -243,7 +243,7 @@ export const multipleChoice = () => html`
${lazyRender( ${lazyRender(
listboxData.map( listboxData.map(
(entry, i) => html` (entry, i) => html`
<lion-option .choiceValue="${entry}" ?checked="${i === 0}>${entry}"</lion-option> <lion-option .choiceValue="${entry}" ?checked="${i === 0}">${entry}</lion-option>
`, `,
), ),
)} )}
@ -266,7 +266,7 @@ export const multipleCustomizableChoice = () => html`
${lazyRender( ${lazyRender(
listboxData.map( listboxData.map(
(entry, i) => html` (entry, i) => html`
<lion-option .choiceValue="${entry}" ?checked="${i === 0}>${entry}"</lion-option> <lion-option .choiceValue="${entry}" ?checked="${i === 0}">${entry}</lion-option>
`, `,
), ),
)} )}

View file

@ -22,6 +22,7 @@ const matchA11ySpanReverseFns = new WeakMap();
* @typedef {import('@lion/ui/types/form-core.js').ChoiceInputHost} ChoiceInputHost * @typedef {import('@lion/ui/types/form-core.js').ChoiceInputHost} ChoiceInputHost
* @typedef {import('@lion/ui/types/form-core.js').FormControlHost} FormControlHost * @typedef {import('@lion/ui/types/form-core.js').FormControlHost} FormControlHost
* @typedef {import('../types/SelectionDisplay.js').SelectionDisplay} SelectionDisplay * @typedef {import('../types/SelectionDisplay.js').SelectionDisplay} SelectionDisplay
* @typedef {LionOption & { onFilterUnmatch?:function; onFilterMatch?:function }} OptionWithFilterFn
*/ */
/** /**
@ -201,8 +202,30 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
reset() { reset() {
super.reset(); super.reset();
// @ts-ignore _initialModelValue comes from ListboxMixin if (!this.multipleChoice) {
this.value = this._initialModelValue; // @ts-ignore _initialModelValue comes from ListboxMixin
this.value = this._initialModelValue;
}
this._resetListboxOptions();
}
/**
* @protected
*/
_resetListboxOptions() {
this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, idx) => {
this._unhighlightMatchedOption(option);
if (!this.showAllOnEmpty || !this.opened) {
// eslint-disable-next-line no-param-reassign
option.style.display = 'none';
} else {
// eslint-disable-next-line no-param-reassign
option.style.display = '';
option.setAttribute('aria-posinset', `${idx + 1}`);
option.setAttribute('aria-setsize', `${this.formElements.length}`);
option.removeAttribute('aria-hidden');
}
});
} }
/** /**
@ -704,11 +727,13 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
*/ */
_listboxOnClick(ev) { _listboxOnClick(ev) {
super._listboxOnClick(ev); super._listboxOnClick(ev);
this._inputNode.focus(); this._inputNode.focus();
if (!this.multipleChoice) { if (!this.multipleChoice) {
this.activeIndex = -1; this.activeIndex = -1;
this.opened = false; this.opened = false;
} else {
this._inputNode.value = '';
this._resetListboxOptions();
} }
} }
@ -897,7 +922,6 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
const autoselect = this._autoSelectCondition(); const autoselect = this._autoSelectCondition();
const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none'; const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none';
/** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */
this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, i) => { this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, i) => {
// [1]. Decide whether option should be shown // [1]. Decide whether option should be shown
const matches = this.matchCondition(option, curValue); const matches = this.matchCondition(option, curValue);
@ -983,7 +1007,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
// [8]. These values will help computing autofill intentions next autocomplete cycle // [8]. These values will help computing autofill intentions next autocomplete cycle
this.__prevCboxValueNonSelected = curValue; this.__prevCboxValueNonSelected = curValue;
// See test 'computation of "user intends autofill" works correctly afer autofill' // See test 'computation of "user intends autofill" works correctly after autofill'
this.__prevCboxValue = this._inputNode.value; this.__prevCboxValue = this._inputNode.value;
this.__hadSelectionLastAutofill = this.__hadSelectionLastAutofill =
this._inputNode.value.length !== this._inputNode.selectionStart; this._inputNode.value.length !== this._inputNode.selectionStart;
@ -1120,16 +1144,16 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
ev.preventDefault(); ev.preventDefault();
this.modelValue = this.parser([...this.modelValue, this._inputNode.value]); this.modelValue = this.parser([...this.modelValue, this._inputNode.value]);
this._inputNode.value = ''; this._inputNode.value = '';
this.opened = false; this.opened = false;
} else { } else {
super._listboxOnKeyDown(ev); super._listboxOnKeyDown(ev);
// TODO: should we clear the input value here when allowCustomChoice is false? this._resetListboxOptions();
// For now, we don't...
} }
if (!this.multipleChoice) { if (!this.multipleChoice) {
this.opened = false; this.opened = false;
} else {
this._inputNode.value = '';
} }
break; break;
default: { default: {
@ -1168,7 +1192,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
.filter(option => diff.includes(option.choiceValue)) .filter(option => diff.includes(option.choiceValue))
.map(option => this._getTextboxValueFromOption(option)) .map(option => this._getTextboxValueFromOption(option))
.join(' '); .join(' ');
this._setTextboxValue(newValue); // or last selected value? this._setTextboxValue(newValue);
} }
} }

View file

@ -81,6 +81,54 @@ describe('lion-combobox', () => {
await performChecks(); await performChecks();
}); });
it('hides all options on reset()', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
const visibleOptions = () => options.filter(o => o.style.display !== 'none');
mimicUserTyping(el, 'cha');
await el.updateComplete;
expect(visibleOptions().length).to.equal(1);
el.reset();
await el.updateComplete;
expect(visibleOptions().length).to.equal(0);
});
it('shows all options on reset() when showAllOnEmpty is set to true and overlay was open', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" show-all-on-empty>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
mimicUserTyping(el, 'cha');
await el.updateComplete;
expect(visibleOptions().length).to.equal(1);
expect(el.opened).to.be.true;
el.reset();
await el.updateComplete;
expect(visibleOptions().length).to.equal(4);
});
it('hides listbox on click/enter (when multiple-choice is false)', async () => { it('hides listbox on click/enter (when multiple-choice is false)', async () => {
const el = /** @type {LionCombobox} */ ( const el = /** @type {LionCombobox} */ (
await fixture(html` await fixture(html`
@ -467,13 +515,12 @@ describe('lion-combobox', () => {
mimicUserTyping(el, 'a'); mimicUserTyping(el, 'a');
await el.updateComplete; await el.updateComplete;
const visibleOptions = options.filter(o => o.style.display !== 'none'); const visibleOptions = () => options.filter(o => o.style.display !== 'none');
expect(visibleOptions.length).to.equal(3, 'after input'); expect(visibleOptions().length).to.equal(3, 'after input');
el.clear(); el.clear();
await el.updateComplete; await el.updateComplete;
const visibleOptions2 = options.filter(o => o.style.display !== 'none'); expect(visibleOptions().length).to.equal(0, 'after clear');
expect(visibleOptions2.length).to.equal(0, 'after clear');
}); });
it('resets modelValue and textbox value on reset()', async () => { it('resets modelValue and textbox value on reset()', async () => {
@ -508,7 +555,7 @@ describe('lion-combobox', () => {
el2.reset(); el2.reset();
expect(el2.modelValue).to.deep.equal(['Artichoke']); expect(el2.modelValue).to.deep.equal(['Artichoke']);
// @ts-ignore [allow-protected] in test // @ts-ignore [allow-protected] in test
expect(el2._inputNode.value).to.equal('Artichoke'); expect(el2._inputNode.value).to.equal('');
}); });
it('syncs textbox to modelValue', async () => { it('syncs textbox to modelValue', async () => {
@ -735,7 +782,7 @@ describe('lion-combobox', () => {
await el.updateComplete; await el.updateComplete;
}); });
it('allows manyu custom selections when multi-choice when requireOptionMatch is false', async () => { it('allows many custom selections when multi-choice when requireOptionMatch is false', async () => {
const el = /** @type {LionCombobox} */ ( const el = /** @type {LionCombobox} */ (
await fixture(html` await fixture(html`
<lion-combobox <lion-combobox
@ -2088,7 +2135,7 @@ describe('lion-combobox', () => {
}); });
}); });
it('highlights first occcurence of matching option', async () => { it('highlights first occurrence of matching option', async () => {
const el = /** @type {LionCombobox} */ ( const el = /** @type {LionCombobox} */ (
await fixture(html` await fixture(html`
<lion-combobox name="foo" match-mode="all"> <lion-combobox name="foo" match-mode="all">
@ -2170,6 +2217,28 @@ describe('lion-combobox', () => {
); );
}); });
it('resets removes highlights on reset()', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" match-mode="all">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
mimicUserTyping(/** @type {LionCombobox} */ (el), 'c');
await el.updateComplete;
expect(options[0]).lightDom.to.equal(`<span aria-label="Artichoke">Arti<b>c</b>hoke</span>`);
el.reset();
await el.updateComplete;
expect(options[0]).lightDom.to.equal(`Artichoke`);
});
it('synchronizes textbox when autocomplete is "inline" or "both"', async () => { it('synchronizes textbox when autocomplete is "inline" or "both"', async () => {
const el = /** @type {LionCombobox} */ ( const el = /** @type {LionCombobox} */ (
await fixture(html` await fixture(html`
@ -3018,6 +3087,64 @@ describe('lion-combobox', () => {
expect(el.opened).to.equal(true); expect(el.opened).to.equal(true);
}); });
it('clears textbox after selection of a new item on [enter]', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
const options = el.formElements;
mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
const visibleOptions = () => options.filter(o => o.style.display !== 'none');
expect(visibleOptions().length).to.equal(1);
// N.B. we do only trigger keydown here (and not mimicKeypress (both keyup and down)),
// because this closely mimics what happens in the browser
_inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.opened).to.equal(true);
expect(visibleOptions().length).to.equal(0);
expect(_inputNode.value).to.equal('');
});
it('clears textbox after selection of a new item on click', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
const options = el.formElements;
mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
const visibleOptions = () => options.filter(o => o.style.display !== 'none');
expect(visibleOptions().length).to.equal(1);
visibleOptions()[0].click();
expect(el.opened).to.equal(true);
expect(visibleOptions().length).to.equal(0);
expect(_inputNode.value).to.equal('');
});
it('submits form on [Enter] when listbox is closed', async () => { it('submits form on [Enter] when listbox is closed', async () => {
const submitSpy = sinon.spy(e => e.preventDefault()); const submitSpy = sinon.spy(e => e.preventDefault());
const el = /** @type {HTMLFormElement} */ ( const el = /** @type {HTMLFormElement} */ (