fix(combobox): update the code to when show and hide the overlay, to be more robust (#2301)

This commit is contained in:
gerjanvangeest 2024-06-10 15:10:08 +02:00 committed by GitHub
parent 57597bb9ba
commit b50b960daa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 149 additions and 88 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[combobox] update the code to when show and hide the overlay, to be more robust

View file

@ -456,10 +456,14 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
*/
this.__listboxContentChanged = false;
/** @type {EventListener}
* @protected
*/
this._onKeyUp = this._onKeyUp.bind(this);
/** @type {EventListener}
* @private
*/
this.__requestShowOverlay = this.__requestShowOverlay.bind(this);
this._textboxOnClick = this._textboxOnClick.bind(this);
/** @type {EventListener}
* @protected
*/
@ -500,9 +504,6 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
}
}
}
if (name === 'focused' && this.focused) {
this.__requestShowOverlay();
}
}
/**
@ -614,11 +615,6 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
* that wraps the listbox with options
*
* @example
* _showOverlayCondition(options) {
* return this.focused || super._showOverlayCondition(options);
* }
*
* @example
* _showOverlayCondition({ lastKey }) {
* return lastKey === 'ArrowDown';
* }
@ -635,19 +631,20 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
// TODO: batch all pending condition triggers in __pendingShowTriggers, reducing race conditions
// eslint-disable-next-line class-methods-use-this
_showOverlayCondition({ lastKey }) {
const hideOn = ['Tab', 'Escape', 'Enter'];
if (lastKey && hideOn.includes(lastKey)) {
const alwaysHideOn = ['Tab', 'Escape'];
const notMultipleChoiceHideOn = ['Enter'];
if (
lastKey &&
(alwaysHideOn.includes(lastKey) ||
(!this.multipleChoice && notMultipleChoiceHideOn.includes(lastKey)))
) {
return false;
}
if (this.showAllOnEmpty && this.focused) {
if (this.filled || this.showAllOnEmpty) {
return true;
}
// when no keyboard action involved (on focused change), return current opened state
if (!lastKey) {
return /** @type {boolean} */ (this.opened);
}
return true;
return /** @type {boolean} */ (this.opened);
}
/**
@ -678,9 +675,14 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
this.__listboxContentChanged = true;
}
/**
* @param {Event} ev
* @protected
*/
// eslint-disable-next-line no-unused-vars
_textboxOnInput() {
_textboxOnInput(ev) {
this.__shouldAutocompleteNextUpdate = true;
this.opened = true;
}
/**
@ -1068,7 +1070,8 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
*/
_setupOpenCloseListeners() {
super._setupOpenCloseListeners();
this._inputNode.addEventListener('keyup', this.__requestShowOverlay);
this._inputNode.addEventListener('keyup', this._onKeyUp);
this._inputNode.addEventListener('click', this._textboxOnClick);
}
/**
@ -1077,7 +1080,8 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
*/
_teardownOpenCloseListeners() {
super._teardownOpenCloseListeners();
this._inputNode.removeEventListener('keyup', this.__requestShowOverlay);
this._inputNode.removeEventListener('keyup', this._onKeyUp);
this._inputNode.removeEventListener('click', this._textboxOnClick);
}
/**
@ -1225,9 +1229,9 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
/**
* @param {KeyboardEvent} [ev]
* @private
* @protected
*/
__requestShowOverlay(ev) {
_onKeyUp(ev) {
const lastKey = ev && ev.key;
this.opened = this._showOverlayCondition({
lastKey,
@ -1235,6 +1239,15 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
});
}
/**
* @param {FocusEvent} [ev]
* @protected
*/
// eslint-disable-next-line no-unused-vars
_textboxOnClick(ev) {
this.opened = this._showOverlayCondition({});
}
clear() {
this.value = '';
super.clear();

View file

@ -93,11 +93,9 @@ describe('lion-combobox', () => {
`)
);
const { _comboboxNode, _listboxNode } = getComboboxMembers(el);
const { _listboxNode } = getComboboxMembers(el);
async function open() {
// activate opened listbox
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
mimicUserTyping(el, 'ch');
return el.updateComplete;
}
@ -150,7 +148,7 @@ describe('lion-combobox', () => {
await performChecks();
});
it('shows overlay on focusin', async () => {
it('shows overlay on click', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .showAllOnEmpty="${true}">
@ -161,15 +159,15 @@ describe('lion-combobox', () => {
</lion-combobox>
`)
);
const { _comboboxNode } = getComboboxMembers(el);
const { _inputNode } = getComboboxMembers(el);
expect(el.opened).to.be.false;
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
_inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.be.true;
});
it('hides overlay on focusin after [Escape]', async () => {
it('hides overlay on [Escape] after being opened', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .showAllOnEmpty="${true}">
@ -183,7 +181,7 @@ describe('lion-combobox', () => {
const { _comboboxNode, _inputNode } = getComboboxMembers(el);
expect(el.opened).to.be.false;
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
_comboboxNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.be.true;
_inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
@ -203,11 +201,9 @@ describe('lion-combobox', () => {
`)
);
const { _comboboxNode, _listboxNode, _inputNode } = getComboboxMembers(el);
const { _listboxNode, _inputNode } = getComboboxMembers(el);
async function open() {
// activate opened listbox
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
mimicUserTyping(el, 'ch');
return el.updateComplete;
}
@ -870,10 +866,10 @@ describe('lion-combobox', () => {
});
describe('Overlay visibility', () => {
it('does not show overlay on focusin', async () => {
it('does not show overlay on click', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice>
<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>
@ -884,7 +880,26 @@ describe('lion-combobox', () => {
const { _comboboxNode } = getComboboxMembers(el);
expect(el.opened).to.equal(false);
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
_comboboxNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.equal(false);
});
it('shows overlay on click when filled', 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 { _comboboxNode } = getComboboxMembers(el);
el.modelValue = 'Art';
expect(el.opened).to.equal(false);
_comboboxNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.equal(false);
});
@ -914,7 +929,7 @@ describe('lion-combobox', () => {
expect(el.opened).to.equal(false);
// step [1]
_inputNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
_inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.equal(false);
@ -948,10 +963,7 @@ describe('lion-combobox', () => {
`)
);
const { _comboboxNode, _inputNode } = getComboboxMembers(el);
// open
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
const { _inputNode } = getComboboxMembers(el);
mimicUserTyping(el, 'art');
await el.updateComplete;
@ -963,6 +975,32 @@ describe('lion-combobox', () => {
expect(_inputNode.value).to.equal('');
});
it('hides overlay on [Enter]', 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 { _inputNode } = getComboboxMembers(el);
mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
expect(_inputNode.value).to.equal('Artichoke');
// 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(false);
expect(_inputNode.value).to.equal('Artichoke');
});
it('hides overlay on [Tab]', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
@ -975,10 +1013,7 @@ describe('lion-combobox', () => {
`)
);
const { _comboboxNode, _inputNode } = getComboboxMembers(el);
// open
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
const { _inputNode } = getComboboxMembers(el);
mimicUserTyping(el, 'art');
await el.updateComplete;
@ -992,6 +1027,32 @@ describe('lion-combobox', () => {
expect(_inputNode.value).to.equal('Artichoke');
});
it('keeps overlay open on [Shift]', 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 { _inputNode } = getComboboxMembers(el);
mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
expect(_inputNode.value).to.equal('Artichoke');
// 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: 'Shift' }));
expect(el.opened).to.equal(true);
expect(_inputNode.value).to.equal('Artichoke');
});
it('clears checkedIndex on empty text', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
@ -1004,10 +1065,7 @@ describe('lion-combobox', () => {
`)
);
const { _comboboxNode, _inputNode } = getComboboxMembers(el);
// open
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
const { _inputNode } = getComboboxMembers(el);
mimicUserTyping(el, 'art');
await el.updateComplete;
@ -1043,34 +1101,6 @@ describe('lion-combobox', () => {
// NB: If this becomes a suite, move to separate file
describe('Subclassers', () => {
it('allows to control overlay visibility via "_showOverlayCondition"', async () => {
class ShowOverlayConditionCombobox extends LionCombobox {
/** @param {{ currentValue: string, lastKey:string }} options */
_showOverlayCondition(options) {
return this.focused || super._showOverlayCondition(options);
}
}
const tagName = defineCE(ShowOverlayConditionCombobox);
const tag = unsafeStatic(tagName);
const el = /** @type {LionCombobox} */ (
await fixture(html`
<${tag} 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>
</${tag}>
`)
);
const { _comboboxNode } = getComboboxMembers(el);
expect(el.opened).to.equal(false);
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.equal(true);
});
it('allows to control overlay visibility via "_showOverlayCondition": should not display overlay if currentValue length condition is not fulfilled', async () => {
class ShowOverlayConditionCombobox extends LionCombobox {
/** @param {{ currentValue: string, lastKey:string }} options */
@ -1164,11 +1194,8 @@ describe('lion-combobox', () => {
`)
);
const options = el.formElements;
const { _comboboxNode } = getComboboxMembers(el);
expect(el.opened).to.equal(false);
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
@ -1208,11 +1235,8 @@ describe('lion-combobox', () => {
`)
);
const options = el.formElements;
const { _comboboxNode } = getComboboxMembers(el);
expect(el.opened).to.equal(false);
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
mimicUserTyping(el, 'art');
expect(el.opened).to.equal(true);
await el.updateComplete;
@ -2948,7 +2972,7 @@ describe('lion-combobox', () => {
describe('Multiple Choice', () => {
// TODO: possibly later share test with select-rich if it officially supports multipleChoice
it('does not close listbox on click/enter', async () => {
it('does not close listbox on click', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice>
@ -2960,10 +2984,7 @@ describe('lion-combobox', () => {
`)
);
const { _comboboxNode } = getComboboxMembers(el);
// activate opened listbox
_comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
mimicUserTyping(el, 'ch');
await el.updateComplete;
@ -2971,7 +2992,29 @@ describe('lion-combobox', () => {
const visibleOptions = el.formElements.filter(o => o.style.display !== 'none');
visibleOptions[0].click();
expect(el.opened).to.equal(true);
mimicKeyPress(visibleOptions[1], 'Enter');
});
it('does not close listbox 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);
mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
// 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);
});