feat(combobox): subclasser features and fixes
This commit is contained in:
parent
8cd22107ea
commit
143cdb5ac6
4 changed files with 427 additions and 102 deletions
|
|
@ -67,7 +67,7 @@ to the configurable values `none`, `list`, `inline` and `both`.
|
|||
| | list | filter | focus | check | complete |
|
||||
| -----: | :--: | :----: | :---: | :---: | :------: |
|
||||
| none | ✓ | | | | |
|
||||
| list | ✓ | ✓ | ✓ | ✓ | |
|
||||
| list | ✓ | ✓ | | | |
|
||||
| inline | ✓ | | ✓ | ✓ | ✓ |
|
||||
| both | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue