feat(combobox): subclasser features and fixes

This commit is contained in:
Thijs Louisse 2020-10-28 12:01:00 +01:00
parent 8cd22107ea
commit 143cdb5ac6
4 changed files with 427 additions and 102 deletions

View file

@ -67,7 +67,7 @@ to the configurable values `none`, `list`, `inline` and `both`.
| | list | filter | focus | check | complete |
| -----: | :--: | :----: | :---: | :---: | :------: |
| none | ✓ | | | | |
| list | ✓ | ✓ | ✓ | ✓ | |
| list | ✓ | ✓ | | | |
| inline | ✓ | | ✓ | ✓ | ✓ |
| both | ✓ | ✓ | ✓ | ✓ | ✓ |

View file

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

View file

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

View file

@ -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`
<lion-combobox name="foo" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -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`
<lion-combobox name="foo">
@ -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`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -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`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -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>
<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}>
`));
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`
<lion-combobox name="foo" autocomplete="none">
<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>
`));
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`
<lion-combobox name="foo" autocomplete="inline">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -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`
<lion-combobox name="foo" autocomplete="inline">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -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>
<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}>
`));
// 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`
<lion-combobox name="foo" autocomplete="none">
<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>
`));
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`
<lion-combobox name="foo" autocomplete="none" 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>
`));
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>
<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}>
`));
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', () => {
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
/** @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