From 143cdb5ac638dfe92278406ad655aa8209768321 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Wed, 28 Oct 2020 12:01:00 +0100 Subject: [PATCH] feat(combobox): subclasser features and fixes --- packages/combobox/README.md | 2 +- packages/combobox/package.json | 3 +- packages/combobox/src/LionCombobox.js | 228 ++++++++++---- packages/combobox/test/lion-combobox.test.js | 296 ++++++++++++++++--- 4 files changed, 427 insertions(+), 102 deletions(-) diff --git a/packages/combobox/README.md b/packages/combobox/README.md index 583989a55..1d41e8446 100644 --- a/packages/combobox/README.md +++ b/packages/combobox/README.md @@ -67,7 +67,7 @@ to the configurable values `none`, `list`, `inline` and `both`. | | list | filter | focus | check | complete | | -----: | :--: | :----: | :---: | :---: | :------: | | none | ✓ | | | | | -| list | ✓ | ✓ | ✓ | ✓ | | +| list | ✓ | ✓ | | | | | inline | ✓ | | ✓ | ✓ | ✓ | | both | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/packages/combobox/package.json b/packages/combobox/package.json index 98c419e83..ade680fd7 100644 --- a/packages/combobox/package.json +++ b/packages/combobox/package.json @@ -38,7 +38,8 @@ "docs/md-combobox/md-combobox.js", "docs/md-combobox/md-input.js", "docs/md-combobox/style/md-ripple.js", - "docs/md-combobox/style/load-roboto.js" + "docs/md-combobox/style/load-roboto.js", + "docs/google-combobox/google-combobox.js" ], "dependencies": { "@lion/core": "0.13.2", diff --git a/packages/combobox/src/LionCombobox.js b/packages/combobox/src/LionCombobox.js index 06d49e418..d73528d81 100644 --- a/packages/combobox/src/LionCombobox.js +++ b/packages/combobox/src/LionCombobox.js @@ -70,15 +70,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) { ]; } - /** - * @enhance FormControlMixin - add form-control to [slot=input] instead of _inputNode - */ - _enhanceLightDomClasses() { - if (this.querySelector('[slot=input]')) { - this.querySelector('[slot=input]').classList.add('form-control'); - } - } - /** * @enhance FormControlMixin - add slot[name=selection-display] */ @@ -104,7 +95,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } /** - * @enhance FormControlMixin + * @enhance FormControlMixin - add overlay */ _groupTwoTemplate() { return html` ${super._groupTwoTemplate()} ${this._overlayListboxTemplate()}`; @@ -274,7 +265,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { this.__prevCboxValue = ''; /** @type {EventListener} */ - this.__showOverlay = this.__showOverlay.bind(this); + this.__requestShowOverlay = this.__requestShowOverlay.bind(this); /** @type {EventListener} */ this._textboxOnInput = this._textboxOnInput.bind(this); /** @type {EventListener} */ @@ -297,9 +288,13 @@ export class LionCombobox extends OverlayMixin(LionListbox) { if (name === 'disabled' || name === 'readOnly') { this.__setComboboxDisabledAndReadOnly(); } - if (name === 'modelValue' && this.modelValue !== oldValue) { - if (this.modelValue) { - this._setTextboxValue(this.modelValue); + if (name === 'modelValue' && this.modelValue && this.modelValue !== oldValue) { + if (this._syncToTextboxCondition(this.modelValue, this.__oldModelValue)) { + if (!this.multipleChoice) { + this._setTextboxValue(this.modelValue); + } else { + this._syncToTextboxMultiple(this.modelValue, this.__oldModelValue); + } } } } @@ -309,6 +304,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) { */ updated(changedProperties) { super.updated(changedProperties); + if (changedProperties.has('focused')) { + if (this.focused) { + this.__requestShowOverlay(); + } + } if (changedProperties.has('opened')) { if (this.opened) { @@ -318,7 +318,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } if (!this.opened && changedProperties.get('opened') !== undefined) { - this._syncCheckedWithTextboxOnInteraction(); + this.__onOverlayClose(); this.activeIndex = -1; } } @@ -362,6 +362,38 @@ export class LionCombobox extends OverlayMixin(LionListbox) { return idx === 0; // matches beginning of value } + /** + * @overridable + * Allows Sub Classer to control when the overlay should become visible + * Note that this condition is separate from whether the option listbox is + * shown (use 'showAllOnEmpty, matchMode and autocomplete configurations for this') + * + * Separating these conditions allows the user to show different content in the dialog/overlay + * that wraps the listbox with options + * + * @example + * _showOverlayCondition(options) { + * return this.focused || super.showOverlayCondition(options); + * } + * + * @example + * _showOverlayCondition({ lastKey }) { + * return lastKey === 'ArrowDown'; + * } + * + * @example + * _showOverlayCondition(options) { + * return options.currentValue.length > 4 && super.showOverlayCondition(options); + * } + * + * @param {{ currentValue: string, lastKey:string }} options + */ + // eslint-disable-next-line class-methods-use-this + _showOverlayCondition({ lastKey }) { + const doNotOpenOn = ['Tab', 'Esc', 'Enter']; + return lastKey && !doNotOpenOn.includes(lastKey); + } + /** * @param {Event} ev */ @@ -378,7 +410,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) { if (ev.key === 'Tab') { this.opened = false; } - this.__hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart; } /** @@ -397,33 +428,26 @@ export class LionCombobox extends OverlayMixin(LionListbox) { * @param {string} v */ _setTextboxValue(v) { + // Make sure that we don't loose inputNode.selectionStart and inputNode.selectionEnd if (this._inputNode.value !== v) { this._inputNode.value = v; } } - /** - * For multiple choice, a subclasser could do something like: - * @example - * _syncCheckedWithTextboxOnInteraction() { - * super._syncCheckedWithTextboxOnInteraction(); - * if (this.multipleChoice) { - * this._inputNode.value = this.checkedElements.map(o => o.value).join(', '); - * } - * } - * @overridable - */ - _syncCheckedWithTextboxOnInteraction() { - if (!this.multipleChoice && this._inputNode.value === '') { - this._uncheckChildren(); - } - - if (!this.multipleChoice && this.checkedIndex !== -1) { - this._inputNode.value = this.formElements[/** @type {number} */ (this.checkedIndex)].value; + __onOverlayClose() { + if (!this.multipleChoice) { + if (this.checkedIndex !== -1) { + this._inputNode.value = this.formElements[ + /** @type {number} */ (this.checkedIndex) + ].choiceValue; + } + } else { + this._syncToTextboxMultiple(this.modelValue, this.__oldModelValue); } } /** + * @enhance FormControlMixin * We need to extend the repropagation prevention conditions here. * Usually form groups with single choice will not repropagate model-value-changed of an option upwards * if this option itself is not the checked one. We want to prevent duplicates. However, for combobox @@ -475,24 +499,30 @@ export class LionCombobox extends OverlayMixin(LionListbox) { /** * Computes whether a user intends to autofill (inline autocomplete textbox) - * @overridable * @param {{ prevValue:string, curValue:string }} config */ // eslint-disable-next-line class-methods-use-this - _computeUserIntendsAutoFill({ prevValue, curValue }) { + __computeUserIntendsAutoFill({ prevValue, curValue }) { const userIsAddingChars = prevValue.length < curValue.length; const userStartsNewWord = prevValue.length && curValue.length && prevValue[0].toLowerCase() !== curValue[0].toLowerCase(); - return userIsAddingChars || userStartsNewWord; } /* eslint-enable no-param-reassign, class-methods-use-this */ /** - * Matches visibility of listbox options against current ._inputNode contents + * Handles autocompletion. This entails: + * - list: shows a list on keydown character press + * - filter: filters list of potential matches according to matchmode or provided matchCondition + * - focus: automatically focuses closest match (makes it the activedescendant) + * - check: automatically checks/selects closest match when selection-follows-focus is enabled + * (this is the default configuration) + * - complete: completes the textbox value inline (the 'missing characters' will be added as + * selected text) + * */ _handleAutocompletion() { // TODO: this is captured by 'noFilter' @@ -502,8 +532,13 @@ export class LionCombobox extends OverlayMixin(LionListbox) { return; } + const hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart; + const curValue = this._inputNode.value; - const prevValue = this.__hasSelection ? this.__prevCboxValueNonSelected : this.__prevCboxValue; + const prevValue = + hasSelection || this.__hadSelectionLastAutofill + ? this.__prevCboxValueNonSelected + : this.__prevCboxValue; const isEmpty = !curValue; /** @@ -511,38 +546,55 @@ export class LionCombobox extends OverlayMixin(LionListbox) { * @type {LionOption[]} */ const visibleOptions = []; + /** Whether autofill (activeIndex/checkedIndex and ) has taken place in this 'cycle' */ let hasAutoFilled = false; - const userIntendsAutoFill = this._computeUserIntendsAutoFill({ prevValue, curValue }); - const isCandidate = this.autocomplete === 'both' || this.autocomplete === 'inline'; + const userIntendsInlineAutoFill = this.__computeUserIntendsAutoFill({ prevValue, curValue }); + const isInlineAutoFillCandidate = + this.autocomplete === 'both' || this.autocomplete === 'inline'; + const autoselect = this._autoSelectCondition(); // @ts-ignore this.autocomplete === 'none' needs to be there if statement above is removed const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none'; /** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */ this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, i) => { - // [1]. Decide whether otion should be shown + // [1]. Decide whether option should be shown + const matches = this.matchCondition(option, curValue); let show = false; if (isEmpty) { show = this.showAllOnEmpty; } else { - show = noFilter ? true : this.matchCondition(option, curValue); + show = noFilter || matches; } // [2]. Synchronize ._inputNode value and active descendant with closest match - if (isCandidate && !hasAutoFilled && show && userIntendsAutoFill && !option.disabled) { - const stringValues = typeof option.choiceValue === 'string' && typeof curValue === 'string'; - const beginsWith = - stringValues && option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase()) === 0; - - if (beginsWith) { - const prevLen = this._inputNode.value.length; - this._inputNode.value = option.choiceValue; - this._inputNode.selectionStart = prevLen; - this._inputNode.selectionEnd = this._inputNode.value.length; + if (autoselect && !hasAutoFilled && matches && !option.disabled) { + const doAutoSelect = () => { this.activeIndex = i; if (this.selectionFollowsFocus && !this.multipleChoice) { this.setCheckedIndex(this.activeIndex); } hasAutoFilled = true; + }; + + if (userIntendsInlineAutoFill) { + // We should never directly select when removing chars or starting a new word + // This leads to bad UX and unwanted syncing of modelValue (based on checkedIndex) + // and _inputNode.value + + if (isInlineAutoFillCandidate) { + const stringValues = + typeof option.choiceValue === 'string' && typeof curValue === 'string'; + const beginsWith = + stringValues && + option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase()) === 0; + // We only can do proper inline autofilling when the beginning of the word matches + if (beginsWith) { + this.__textboxInlineComplete(option); + doAutoSelect(); + } + } else { + doAutoSelect(); + } } } @@ -578,7 +630,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { }); // [7]. If no autofill took place, we are left with the previously matched option; correct this - if (!hasAutoFilled && isCandidate && !this.multipleChoice) { + if (!hasAutoFilled && autoselect && !this.multipleChoice) { // This means there is no match for checkedIndex this.checkedIndex = -1; } @@ -587,7 +639,8 @@ export class LionCombobox extends OverlayMixin(LionListbox) { this.__prevCboxValueNonSelected = curValue; // See test 'computation of "user intends autofill" works correctly afer autofill' this.__prevCboxValue = this._inputNode.value; - this.__hasSelection = hasAutoFilled; + this.__hadSelectionLastAutofill = + this._inputNode.value.length !== this._inputNode.selectionStart; // [9]. Reposition overlay if (this._overlayCtrl && this._overlayCtrl._popper) { @@ -595,6 +648,23 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } } + __textboxInlineComplete(option = this.formElements[this.activeIndex]) { + const prevLen = this._inputNode.value.length; + this._inputNode.value = option.choiceValue; + this._inputNode.selectionStart = prevLen; + this._inputNode.selectionEnd = this._inputNode.value.length; + } + + /** + * When this condition is false, an end user will have to manually select a suggested + * option from the list (by default when autocomplete is 'none' or 'list'). + * For autocomplete 'both' or 'inline', it will automatically select on a match. + * @overridable + */ + _autoSelectCondition() { + return this.autocomplete === 'both' || this.autocomplete === 'inline'; + } + /** * @enhance ListboxMixin */ @@ -629,7 +699,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { */ _setupOpenCloseListeners() { super._setupOpenCloseListeners(); - this._overlayInvokerNode.addEventListener('keydown', this.__showOverlay); + this._inputNode.addEventListener('keydown', this.__requestShowOverlay); } /** @@ -637,7 +707,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { */ _teardownOpenCloseListeners() { super._teardownOpenCloseListeners(); - this._overlayInvokerNode.removeEventListener('keydown', this.__showOverlay); + this._inputNode.removeEventListener('keydown', this.__requestShowOverlay); } /** @@ -650,7 +720,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) { switch (key) { case 'Escape': this.opened = false; - this.__shouldAutocompleteNextUpdate = true; this._setTextboxValue(''); break; case 'Enter': @@ -665,6 +734,35 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } } + /** + * @param {string|string[]} modelValue + * @param {string|string[]} oldModelValue + */ + // eslint-disable-next-line no-unused-vars + _syncToTextboxCondition(modelValue, oldModelValue) { + return this.autocomplete === 'inline' || this.autocomplete === 'both'; + } + + /** + * @overridable + * Allows to control what happens when checkedIndexes change + * @param {string[]} modelValue + * @param {string[]} oldModelValue + */ + _syncToTextboxMultiple(modelValue, oldModelValue = []) { + const diff = modelValue.filter(x => !oldModelValue.includes(x)); + this._setTextboxValue(diff); // or last selected value? + } + + /** + * @override FormControlMixin - add form-control to [slot=input] instead of _inputNode + */ + _enhanceLightDomClasses() { + if (this.querySelector('[slot=input]')) { + this.querySelector('[slot=input]').classList.add('form-control'); + } + } + __initFilterListbox() { this._handleAutocompletion(); } @@ -705,12 +803,16 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } /** - * @param {KeyboardEvent} ev + * @param {KeyboardEvent} [ev] */ - __showOverlay(ev) { - if (ev.key === 'Tab' || ev.key === 'Esc' || ev.key === 'Enter') { - return; + __requestShowOverlay(ev) { + if ( + this._showOverlayCondition({ + lastKey: ev && ev.key, + currentValue: this._inputNode.value, + }) + ) { + this.opened = true; } - this.opened = true; } } diff --git a/packages/combobox/test/lion-combobox.test.js b/packages/combobox/test/lion-combobox.test.js index 2df02778f..3592dd574 100644 --- a/packages/combobox/test/lion-combobox.test.js +++ b/packages/combobox/test/lion-combobox.test.js @@ -5,6 +5,7 @@ import '../lion-combobox.js'; import { LionOptions } from '@lion/listbox/src/LionOptions.js'; import { browserDetection, LitElement } from '@lion/core'; import { Required } from '@lion/form-core'; +import { LionCombobox } from '../src/LionCombobox.js'; /** * @typedef {import('../src/LionCombobox.js').LionCombobox} LionCombobox @@ -20,7 +21,7 @@ function mimicUserTyping(el, value) { // eslint-disable-next-line no-param-reassign el._inputNode.value = value; el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); - el._overlayInvokerNode.dispatchEvent(new Event('keydown')); + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: value })); } /** @@ -31,31 +32,32 @@ async function mimicUserTypingAdvanced(el, values) { const inputNode = /** @type {HTMLInputElement & {selectionStart:number, selectionEnd:number}} */ (el._inputNode); inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); - let hasSelection = inputNode.selectionStart !== inputNode.selectionEnd; - for (const key of values) { // eslint-disable-next-line no-await-in-loop, no-loop-func await new Promise(resolve => { - setTimeout(() => { - if (key === 'Backspace') { - if (hasSelection) { - inputNode.value = - inputNode.value.slice(0, inputNode.selectionStart) + - inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length); - } else { - inputNode.value = inputNode.value.slice(0, -1); - } - } else if (hasSelection) { + const hasSelection = inputNode.selectionStart !== inputNode.selectionEnd; + + if (key === 'Backspace') { + if (hasSelection) { inputNode.value = inputNode.value.slice(0, inputNode.selectionStart) + - key + inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length); } else { - inputNode.value += key; + inputNode.value = inputNode.value.slice(0, -1); } - hasSelection = false; - inputNode.dispatchEvent(new KeyboardEvent('keydown', { key })); - el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + } else if (hasSelection) { + inputNode.value = + inputNode.value.slice(0, inputNode.selectionStart) + + key + + inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length); + } else { + inputNode.value += key; + } + + inputNode.dispatchEvent(new KeyboardEvent('keydown', { key })); + el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + + el.updateComplete.then(() => { resolve(); }); }); @@ -246,8 +248,8 @@ describe('lion-combobox', () => { }); }); - describe('Listbox visibility', () => { - it('does not show listbox on focusin', async () => { + describe('Overlay visibility', () => { + it('does not show overlay on focusin', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -263,14 +265,14 @@ describe('lion-combobox', () => { expect(el.opened).to.equal(false); }); - it('shows listbox again after select and char keydown', async () => { + it('shows overlay again after select and char keydown', async () => { /** * Scenario: - * [1] user focuses textbox: listbox hidden - * [2] user types char: listbox shows - * [3] user selects "Artichoke": listbox closes, textbox gets value "Artichoke" and textbox + * [1] user focuses textbox: overlay hidden + * [2] user types char: overlay shows + * [3] user selects "Artichoke": overlay closes, textbox gets value "Artichoke" and textbox * still has focus - * [4] user changes textbox value to "Artichoke": the listbox should show again + * [4] user changes textbox value to "Artichoke": the overlay should show again */ const el = /** @type {LionCombobox} */ (await fixture(html` @@ -306,7 +308,7 @@ describe('lion-combobox', () => { expect(el.opened).to.equal(true); }); - it('hides (and clears) listbox on [Escape]', async () => { + it('hides (and clears) overlay on [Escape]', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -329,7 +331,7 @@ describe('lion-combobox', () => { expect(el._inputNode.value).to.equal(''); }); - it('hides listbox on [Tab]', async () => { + it('hides overlay on [Tab]', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -371,13 +373,40 @@ describe('lion-combobox', () => { expect(el._inputNode.value).to.equal('Artichoke'); expect(el.checkedIndex).to.equal(0); - el._inputNode.value = ''; mimicUserTyping(el, ''); + await el.updateComplete; el.opened = false; await el.updateComplete; expect(el.checkedIndex).to.equal(-1); }); + // 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 { + _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> + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + expect(el.opened).to.equal(false); + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.opened).to.equal(true); + }); + }); + describe('Accessibility', () => { it('sets "aria-posinset" and "aria-setsize" on visible entries', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` @@ -783,7 +812,42 @@ describe('lion-combobox', () => { expect(el._inputNode.selectionEnd).to.equal('ch'.length); }); - it('does autocompletion when adding chars', async () => { + it('synchronizes textbox on overlay close', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + expect(el._inputNode.value).to.equal(''); + + async function performChecks(autocomplete, index, valueOnClose) { + await el.updateComplete; + el.opened = true; + el.setCheckedIndex(-1); + await el.updateComplete; + el.autocomplete = autocomplete; + el.setCheckedIndex(index); + el.opened = false; + await el.updateComplete; + expect(el._inputNode.value).to.equal(valueOnClose); + } + + await performChecks('none', 0, 'Artichoke'); + await performChecks('list', 0, 'Artichoke'); + await performChecks('inline', 0, 'Artichoke'); + await performChecks('both', 0, 'Artichoke'); + + el.multipleChoice = true; + // await performChecks('none', [0, 1], 'Chard'); + // await performChecks('list', [0, 1], 'Chard'); + // await performChecks('inline', [0, 1], 'Chard'); + // await performChecks('both', [0, 1], 'Chard'); + }); + + it('does inline autocompletion when adding chars', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -809,7 +873,7 @@ describe('lion-combobox', () => { expect(el.checkedIndex).to.equal(1); }); - it('does autocompletion when changing the word', async () => { + it('does inline autocompletion when changing the word', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -824,7 +888,7 @@ describe('lion-combobox', () => { expect(el.activeIndex).to.equal(1); expect(el.checkedIndex).to.equal(1); - await mimicUserTypingAdvanced(el, 'ic'.split('')); + await mimicUserTypingAdvanced(el, ['i']); await el.updateComplete; expect(el.activeIndex).to.equal(2); expect(el.checkedIndex).to.equal(2); @@ -854,11 +918,37 @@ describe('lion-combobox', () => { // Autocompletion happened. When we go backwards ('Char'), we should not // autocomplete to 'Chard' anymore. - mimicUserTyping(el, 'Char'); + await mimicUserTypingAdvanced(el, ['Backspace']); await el.updateComplete; - expect(el._inputNode.value).to.equal('Char'); // so not 'Chard' - expect(el._inputNode.selectionStart).to.equal('Char'.length); - expect(el._inputNode.selectionEnd).to.equal('Char'.length); + expect(el._inputNode.value).to.equal('Ch'); // so not 'Chard' + expect(el._inputNode.selectionStart).to.equal('Ch'.length); + expect(el._inputNode.selectionEnd).to.equal('Ch'.length); + }); + + describe('Subclassers', () => { + it('allows to configure autoselect', async () => { + class X extends LionCombobox { + _autoSelectCondition() { + return true; + } + } + const tagName = defineCE(X); + const tag = unsafeStatic(tagName); + + const el = /** @type {LionCombobox} */ (await fixture(html` + <${tag} name="foo" autocomplete="list" opened> + Artichoke + Chard + Chicory + Victoria Plum + + `)); + // This ensures autocomplete would be off originally + el.autocomplete = 'list'; + await mimicUserTypingAdvanced(el, 'vi'); // so we have options ['Victoria Plum'] + await el.updateComplete; + expect(el.checkedIndex).to.equal(3); + }); }); it('highlights matching options', async () => { @@ -889,6 +979,125 @@ describe('lion-combobox', () => { expect(options[3]).lightDom.to.equal(`Victoria Plum`); }); + it('synchronizes textbox when autocomplete is "inline" or "both"', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'none'; + el.setCheckedIndex(0); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'list'; + el.setCheckedIndex(0); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'inline'; + el.setCheckedIndex(0); + expect(el._inputNode.value).to.equal('Artichoke'); + + el.setCheckedIndex(-1); + el.autocomplete = 'both'; + el.setCheckedIndex(0); + expect(el._inputNode.value).to.equal('Artichoke'); + }); + + it('synchronizes last index to textbox when autocomplete is "inline" or "both" when multipleChoice', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + expect(el._inputNode.value).to.eql(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'none'; + el.setCheckedIndex([0]); + el.setCheckedIndex([1]); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'list'; + el.setCheckedIndex([0]); + el.setCheckedIndex([1]); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'inline'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke'); + el.setCheckedIndex([1]); + expect(el._inputNode.value).to.equal('Chard'); + + el.setCheckedIndex(-1); + el.autocomplete = 'both'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke'); + el.setCheckedIndex([1]); + expect(el._inputNode.value).to.equal('Chard'); + }); + + describe('Subclassers', () => { + it('allows to override "_syncCheckedWithTextboxMultiple"', async () => { + class X extends LionCombobox { + // eslint-disable-next-line no-unused-vars + _syncToTextboxCondition() { + return true; + } + + // eslint-disable-next-line no-unused-vars + _syncToTextboxMultiple(modelValue, oldModelValue) { + // In a real scenario (depending on how selection display works), + // you could override the default (last selected option) with '' for instance + this._setTextboxValue(`${modelValue}-${oldModelValue}-multi`); + } + } + const tagName = defineCE(X); + const tag = unsafeStatic(tagName); + + const el = /** @type {LionCombobox} */ (await fixture(html` + <${tag} name="foo" autocomplete="none" multiple-choice> + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + el.setCheckedIndex(-1); + el.autocomplete = 'none'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke--multi'); + + el.setCheckedIndex(-1); + el.autocomplete = 'list'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke--multi'); + + el.setCheckedIndex(-1); + el.autocomplete = 'inline'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke--multi'); + + el.setCheckedIndex(-1); + el.autocomplete = 'both'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke--multi'); + }); + }); + describe('Active index behavior', () => { it('sets the active index to the closest match on open by default', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` @@ -919,9 +1128,19 @@ describe('lion-combobox', () => { Victoria Plum `)); + + /** @param {LionCombobox} elm */ + function reset(elm) { + // eslint-disable-next-line no-param-reassign + elm.activeIndex = -1; + // eslint-disable-next-line no-param-reassign + elm.checkedIndex = -1; + } + // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // Example 1. List Autocomplete with Manual Selection: // does not set active at all until user selects + reset(el); el.autocomplete = 'none'; mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; @@ -932,6 +1151,7 @@ describe('lion-combobox', () => { // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // Example 2. List Autocomplete with Automatic Selection: // does not set active at all until user selects + reset(el); el.autocomplete = 'list'; mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; @@ -941,6 +1161,7 @@ describe('lion-combobox', () => { // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // Example 3. List with Inline Autocomplete (mostly, but with aria-autocomplete="inline") + reset(el); el.autocomplete = 'inline'; mimicUserTyping(/** @type {LionCombobox} */ (el), ''); await el.updateComplete; @@ -948,18 +1169,18 @@ describe('lion-combobox', () => { await el.updateComplete; await el.updateComplete; - // TODO: enable this, so it does not open listbox and is different from [autocomplete=both]? - // expect(el.opened).to.be.false; expect(el.activeIndex).to.equal(1); el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); await el.updateComplete; + await el.updateComplete; expect(el.activeIndex).to.equal(-1); expect(el.opened).to.be.false; // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // Example 3. List with Inline Autocomplete + reset(el); el.autocomplete = 'both'; mimicUserTyping(/** @type {LionCombobox} */ (el), ''); await el.updateComplete; @@ -1022,6 +1243,7 @@ describe('lion-combobox', () => { el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await el.updateComplete; expect(el._inputNode.textContent).to.equal(''); + el.formElements.forEach(option => expect(option.active).to.be.false); // change selection, active index should update to closest match