@lion/ui
Version:
A package of extendable web components
1,316 lines (1,157 loc) • 143 kB
JavaScript
import { defineCE, expect, fixture, html, unsafeStatic, waitUntil } from '@open-wc/testing';
import { Required, Unparseable } from '@lion/ui/form-core.js';
import { sendKeys } from '@web/test-runner-commands';
import { LionCombobox } from '@lion/ui/combobox.js';
import { browserDetection, SlotMixin } from '@lion/ui/core.js';
import '@lion/ui/define/lion-combobox.js';
import '@lion/ui/define/lion-listbox.js';
import '@lion/ui/define/lion-option.js';
import { LitElement, nothing } from 'lit';
import sinon from 'sinon';
import {
getFilteredOptionValues,
mimicUserTypingAdvanced,
getComboboxMembers,
mimicUserTyping,
mimicKeyPress,
} from '@lion/ui/combobox-test-helpers.js';
import { isActiveElement } from '../../core/test-helpers/isActiveElement.js';
/**
* @typedef {import('../types/SelectionDisplay.js').SelectionDisplay} SelectionDisplay
* @typedef {import('@lion/ui/listbox.js').LionOption} LionOption
*/
/**
* @param {{ autocomplete?:'none'|'list'|'both', matchMode?:'begin'|'all', disabled?: boolean, readonly?: boolean }} config
*/
async function fruitFixture({ autocomplete, matchMode, disabled, readonly } = {}) {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox label="Search" name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
if (autocomplete) {
el.autocomplete = autocomplete;
}
if (matchMode) {
el.matchMode = matchMode;
}
if (disabled) {
el.disabled = disabled;
}
if (readonly) {
el.readOnly = readonly;
}
await el.updateComplete;
return [el, el.formElements];
}
describe('lion-combobox', () => {
describe('Rendering on API change', () => {
it('renders when options changed', async () => {
const entries = [
{
id: '1',
label: 'Apple',
},
{
id: '2',
label: 'Artichoke',
},
{
id: '3',
label: 'Asparagus',
},
{
id: '4',
label: 'Banana',
},
{
id: '5',
label: 'Pineapple',
},
];
class Wrapper extends LitElement {
static properties = {
...super.properties,
entries: { type: Array },
};
constructor() {
super();
this.entries = entries;
}
render() {
return html`<lion-combobox name="foo">
${this.entries.map(
entry => html`<lion-option .choiceValue="${entry.id}">${entry.label}</lion-option>`,
)}
</lion-combobox>`;
}
}
const tagString = defineCE(Wrapper);
const tag = unsafeStatic(tagString);
const wrapperElement = /** @type {Wrapper} */ (await fixture(html`<${tag}></${tag}>`));
await waitUntil(
() =>
wrapperElement?.shadowRoot?.querySelector('lion-options')?.querySelectorAll('lion-option')
.length === entries.length,
);
wrapperElement.entries = [
{
id: '4',
label: 'Banana',
},
];
await waitUntil(
() =>
wrapperElement?.shadowRoot?.querySelector('lion-options')?.querySelectorAll('lion-option')
.length === wrapperElement.entries.length,
);
const optionElements = wrapperElement.shadowRoot?.querySelectorAll('lion-option');
expect(optionElements?.length).to.equal(wrapperElement.entries.length);
const optionElement = optionElements?.item(0);
expect(optionElement?.textContent).to.equal('Banana');
});
});
describe('Options visibility', () => {
it('hides options when text in input node is cleared after typing something by default', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
async function performChecks() {
await mimicUserTyping(el, 'c');
await el.updateComplete;
expect(visibleOptions().length).to.equal(4, `autocompleteMode is ${el.autocomplete}`);
// Remove [hard], [c]
await mimicUserTypingAdvanced(el, ['Backspace', 'Backspace']);
await el.updateComplete;
expect(visibleOptions().length).to.equal(0, `autocompleteMode is ${el.autocomplete}`);
}
el.autocomplete = 'none';
await performChecks();
el.autocomplete = 'list';
await performChecks();
el.autocomplete = 'inline';
await performChecks();
el.autocomplete = 'both';
await performChecks();
});
it('hides all options on reset()', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
const visibleOptions = () => options.filter(o => o.style.display !== 'none');
await mimicUserTyping(el, 'cha');
await el.updateComplete;
expect(visibleOptions().length).to.equal(1);
el.reset();
await el.updateComplete;
expect(visibleOptions().length).to.equal(0);
});
it('shows all options on reset() when showAllOnEmpty is set to true and overlay was open', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" show-all-on-empty>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
await mimicUserTyping(el, 'cha');
await el.updateComplete;
expect(visibleOptions().length).to.equal(1);
expect(el.opened).to.be.true;
el.reset();
await el.updateComplete;
expect(visibleOptions().length).to.equal(4);
});
it('hides listbox on click/enter (when multiple-choice is false)', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _listboxNode } = getComboboxMembers(el);
async function open() {
await mimicUserTyping(el, 'ch');
return el.updateComplete;
}
await open();
expect(el.opened).to.be.true;
const visibleOptions = el.formElements.filter(o => o.style.display !== 'none');
visibleOptions[0].click();
expect(el.opened).to.be.false;
await open();
expect(el.opened).to.be.true;
el.activeIndex = el.formElements.indexOf(visibleOptions[0]);
mimicKeyPress(_listboxNode, 'Enter');
await el.updateComplete;
expect(el.opened).to.be.false;
});
describe('With ".showAllOnEmpty"', () => {
it('keeps showing options when text in input node is cleared after typing something', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" autocomplete="list" show-all-on-empty>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
async function performChecks() {
await mimicUserTyping(el, 'c');
await el.updateComplete;
expect(visibleOptions().length).to.equal(4);
await mimicUserTyping(el, '');
await el.updateComplete;
expect(visibleOptions().length).to.equal(options.length);
}
el.autocomplete = 'none';
await performChecks();
el.autocomplete = 'list';
await performChecks();
el.autocomplete = 'inline';
await performChecks();
el.autocomplete = 'both';
await performChecks();
});
it('shows overlay on click', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .showAllOnEmpty="${true}">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
expect(el.opened).to.be.false;
_inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.be.true;
});
it('hides overlay on [Escape] after being opened', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .showAllOnEmpty="${true}" 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>
</lion-combobox>
`)
);
await el.updateComplete;
const { _inputNode } = getComboboxMembers(el);
_inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await el.updateComplete;
expect(el.opened).to.be.false;
});
it('hides listbox on click/enter (when multiple-choice is false)', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .showAllOnEmpty="${true}">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _listboxNode, _inputNode } = getComboboxMembers(el);
async function open() {
await mimicUserTyping(el, 'ch');
return el.updateComplete;
}
// FIXME: temp disable for Safari. Works locally, not in build
const isSafari = (() => {
const ua = navigator.userAgent.toLowerCase();
return ua.indexOf('safari') !== -1 && ua.indexOf('chrome') === -1;
})();
if (isSafari) {
return;
}
await open();
expect(el.opened).to.be.true;
const visibleOptions = el.formElements.filter(o => o.style.display !== 'none');
visibleOptions[0].click();
await el.updateComplete;
expect(el.opened).to.be.false;
_inputNode.value = '';
_inputNode.blur();
await open();
await el.updateComplete;
el.activeIndex = el.formElements.indexOf(visibleOptions[0]);
mimicKeyPress(_listboxNode, 'Enter');
expect(el.opened).to.be.false;
});
});
});
describe('Structure', () => {
it('has a listbox node', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'10'}" checked>Item 1</lion-option>
<lion-option .choiceValue="${'20'}">Item 2</lion-option>
</lion-combobox>
`)
);
const { _listboxNode } = getComboboxMembers(el);
expect(_listboxNode).to.exist;
// TODO: worked before, find out what has changed in ScopedElements
// expect(_listboxNode instanceof LionOptions).to.be.true;
expect(el.querySelector('[role=listbox]')).to.equal(_listboxNode);
});
it('has a textbox element', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'10'}" checked>Item 1</lion-option>
<lion-option .choiceValue="${'20'}">Item 2</lion-option>
</lion-combobox>
`)
);
const { _comboboxNode } = getComboboxMembers(el);
expect(_comboboxNode).to.exist;
expect(el.querySelector('[role=combobox]')).to.equal(_comboboxNode);
});
it('has validator "MatchesOption" applied by default', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'10'}" checked>Item 1</lion-option>
<lion-option .choiceValue="${'20'}">Item 2</lion-option>
</lion-combobox>
`)
);
await mimicUserTyping(el, '30');
await el.updateComplete;
expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).to.have.property('MatchesOption');
});
it('keeps slottable provided in `slots` getter as direct host child', async () => {
class MyEl extends SlotMixin(LionCombobox) {
// @ts-ignore
get slots() {
return {
...super.slots,
_lazyRenderedSlot: () => ({
template: this.renderSlot
? html`<span id="lazyRenderedSlotId">(Optional)</span>`
: html`${nothing}`,
renderAsDirectHostChild: true,
}),
};
}
_labelTemplate() {
return html`
<div class="form-field__label">
<slot name="label"></slot>
<slot name="_lazyRenderedSlot"></slot>
<slot></slot>
</div>
`;
}
constructor() {
super();
this.renderSlot = false;
}
}
const tagName = defineCE(MyEl);
const wrappingTag = unsafeStatic(tagName);
const el = /** @type {MyEl} */ (
await fixture(html`
<${wrappingTag} label="my label">
<lion-option .choiceValue="${'1'}">${'one'}</lion-option>
</${wrappingTag}>
`)
);
await el.registrationComplete;
el.renderSlot = true;
await el.updateComplete;
const lazyRenderedSlot = el.querySelector('#lazyRenderedSlotId');
expect(lazyRenderedSlot?.parentElement === el).to.be.true;
});
});
describe('Values', () => {
it('syncs modelValue with textbox', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .modelValue="${'10'}">
<lion-option .choiceValue="${'10'}" checked>Item 1</lion-option>
<lion-option .choiceValue="${'20'}">Item 2</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
expect(_inputNode.value).to.equal('10');
el.modelValue = '20';
await el.updateComplete;
expect(_inputNode.value).to.equal('20');
});
it('sets modelValue to Unparseable if no option is selected', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .modelValue="${'Artichoke'}">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
expect(el.modelValue).to.equal('Artichoke');
expect(el.formElements[0].checked).to.be.true;
el.setCheckedIndex(-1);
el.__shouldAutocompleteNextUpdate = true;
await el.updateComplete;
expect(el.modelValue instanceof Unparseable).to.be.true;
expect(el.modelValue.viewValue).to.equal('Artichoke');
expect(el.formElements[0].checked).to.be.false;
el.setCheckedIndex(-1);
_inputNode.value = 'Foo';
el.__shouldAutocompleteNextUpdate = true;
await el.updateComplete;
expect(el.modelValue instanceof Unparseable).to.be.true;
expect(el.modelValue.viewValue).to.equal('Foo');
expect(el.formElements[0].checked).to.be.false;
el.setCheckedIndex(0);
el.__shouldAutocompleteNextUpdate = true;
await el.updateComplete;
expect(el.modelValue instanceof Unparseable).to.be.false;
expect(el.modelValue).to.equal('Artichoke');
expect(el.formElements[0].checked).to.be.true;
});
it('sets modelValue to _inputNode.value if no option is selected when requireOptionMatch is false', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .modelValue="${'Artichoke'}" .requireOptionMatch="${false}">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
expect(el.modelValue).to.equal('Artichoke');
expect(el.formElements[0].checked).to.be.true;
el.setCheckedIndex(-1);
el.__shouldAutocompleteNextUpdate = true;
await el.updateComplete;
expect(el.modelValue).to.equal('Artichoke');
expect(el.formElements[0].checked).to.be.true;
el.setCheckedIndex(-1);
_inputNode.value = 'Foo';
el.__shouldAutocompleteNextUpdate = true;
await el.updateComplete;
expect(el.modelValue).to.equal('Foo');
expect(el.formElements[0].checked).to.be.false;
});
it('sets modelValue to empty array if no option is selected for multiple choice', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice .modelValue="${['Artichoke']}">
<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.modelValue).to.deep.equal(['Artichoke']);
expect(el.formElements[0].checked).to.be.true;
el.setCheckedIndex([]);
await el.updateComplete;
expect(el.modelValue).to.deep.equal([]);
expect(el.formElements[0].checked).to.be.false;
});
it('clears modelValue and textbox value on clear()', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .modelValue="${'Artichoke'}">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
el.clear();
expect(el.modelValue).to.equal('');
expect(_inputNode.value).to.equal('');
const el2 = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice .modelValue="${['Artichoke']}">
<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>
`)
);
el2.clear();
expect(el2.modelValue).to.eql([]);
// @ts-ignore [allow-protected] in test
expect(el2._inputNode.value).to.equal('');
});
it('correctly emits event with an empty value when clear() is called', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(
html`<lion-combobox>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'green'}>Green</lion-option>
<lion-option .choiceValue=${'blue'}>Blue</lion-option>
</lion-combobox>`,
)
);
el.modelValue = 'red';
await el.updateComplete;
el.addEventListener('model-value-changed', ({ target }) => {
expect(target).to.not.be.null;
const { modelValue, value } = /** @type {LionCombobox} */ (target);
expect(value).to.equal('');
expect(modelValue).to.equal('');
});
el.clear();
await el.updateComplete;
});
it('updates option list after clear()', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
await mimicUserTyping(el, 'a');
await el.updateComplete;
const visibleOptions = () => options.filter(o => o.style.display !== 'none');
expect(visibleOptions().length).to.equal(3, 'after input');
el.clear();
await el.updateComplete;
expect(visibleOptions().length).to.equal(0, 'after clear');
});
it('resets modelValue and textbox value on reset()', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .modelValue="${'Artichoke'}">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
el.modelValue = 'Chard';
el.reset();
expect(el.modelValue).to.equal('Artichoke');
expect(_inputNode.value).to.equal('Artichoke');
const el2 = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice .modelValue="${['Artichoke']}">
<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>
`)
);
el2.modelValue = ['Artichoke', 'Chard'];
el2.reset();
expect(el2.modelValue).to.deep.equal(['Artichoke']);
// @ts-ignore [allow-protected] in test
expect(el2._inputNode.value).to.equal('');
});
it('syncs textbox to modelValue', async () => {
for (const autocompleteMode of ['none', 'list', 'inline', 'both']) {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" show-all-on-empty .autocomplete="${autocompleteMode}">
<lion-option .choiceValue="${'Aa'}" checked>Aa</lion-option>
<lion-option .choiceValue="${'Bb'}">Bb</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
expect(_inputNode.value).to.equal('Aa', `autocompleteMode is ${autocompleteMode}`);
el.formElements[0].click();
await el.updateComplete;
expect(_inputNode.value).to.equal('Aa', `autocompleteMode is ${autocompleteMode}`);
expect(el.checkedIndex).to.equal(0, `autocompleteMode is ${autocompleteMode}`);
await mimicUserTyping(el, 'B');
await el.updateComplete;
if (autocompleteMode === 'none' || autocompleteMode === 'list') {
expect(el.modelValue).to.deep.equal(
{ type: 'unparseable', viewValue: 'B' },
`autocompleteMode is ${autocompleteMode}`,
);
await el.updateComplete;
expect(el.checkedIndex).to.equal(-1, `autocompleteMode is ${autocompleteMode}`);
} else {
expect(el.modelValue).to.equal('Bb', `autocompleteMode is ${autocompleteMode}`);
expect(el.checkedIndex).to.equal(1, `autocompleteMode is ${autocompleteMode}`);
}
}
});
it('works with Required validator', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .validators=${[new Required()]}>
<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>
`)
);
el.submitted = true;
await el.feedbackComplete;
expect(el.hasFeedbackFor).to.include('error', 'hasFeedbackFor');
await el.feedbackComplete;
expect(el.showsFeedbackFor).to.include('error', 'showsFeedbackFor');
});
it('ignores empty string modelValue inputs', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" multiple-choice 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>
`)
);
el.requireOptionMatch = false;
await el.updateComplete;
const { _inputNode } = getComboboxMembers(el);
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.modelValue).to.eql([]);
await mimicUserTyping(el, ' ');
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.modelValue).to.eql([]);
});
it('allows a value outside of the option list when requireOptionMatch is false', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .validators=${[new Required()]}>
<lion-option checked .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>
`)
);
el.requireOptionMatch = false;
const { _inputNode } = getComboboxMembers(el);
expect(el.checkedIndex).to.equal(0);
await mimicUserTyping(el, 'Foo');
_inputNode.dispatchEvent(new Event('input'));
await el.updateComplete;
expect(el.checkedIndex).to.equal(-1);
expect(el.modelValue).to.equal('Foo');
expect(_inputNode.value).to.equal('Foo');
});
it("doesn't select any similar options after using delete when requireOptionMatch is false", async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" .validators=${[new Required()]}>
<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>
`)
);
el.requireOptionMatch = false;
const { _inputNode } = getComboboxMembers(el);
await mimicUserTyping(el, 'Art');
await el.updateComplete;
await mimicUserTypingAdvanced(el, ['Delete']);
await el.updateComplete;
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.checkedIndex).to.equal(-1);
expect(el.modelValue).to.equal('Art');
expect(_inputNode.value).to.equal('Art');
});
it("when removing a letter it won't select the option", async () => {
// We don't autocomplete when characters are removed
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option checked .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
expect(el.checkedIndex).to.equal(0);
// Simulate backspace deleting the char at the end of the string
mimicKeyPress(_inputNode, 'Backspace');
_inputNode.dispatchEvent(new Event('input'));
const arr = _inputNode.value.split('');
arr.splice(_inputNode.value.length - 1, 1);
_inputNode.value = arr.join('');
await el.updateComplete;
el.dispatchEvent(new Event('blur'));
expect(el.checkedIndex).to.equal(-1);
expect(el.modelValue instanceof Unparseable).to.be.true;
expect(el.modelValue.viewValue).to.equal('Artichok');
});
it('allows the user to override the parser', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox
name="foo"
.parser="${/** @param {string} value */ value => value.replace(/[0-9]/g, '')}"
>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
el.setCheckedIndex(-1);
_inputNode.value = 'Foo123';
el.__shouldAutocompleteNextUpdate = true;
await el.updateComplete;
expect(el.modelValue).to.equal('Foo');
expect(el.formElements[0].checked).to.be.false;
});
it('allows custom selections when multi-choice when requireOptionMatch is false', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox
name="foo"
multiple-choice
.validators=${[new Required()]}
autocomplete="none"
>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option checked .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
el.requireOptionMatch = false;
await el.updateComplete;
const { _inputNode } = getComboboxMembers(el);
expect(el.modelValue).to.eql(['Chard']);
expect(el.checkedIndex).to.eql([1]);
await mimicUserTyping(el, 'Foo');
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.modelValue).to.eql(['Chard', 'Foo']);
expect(el.checkedIndex).to.eql([1]);
await mimicUserTyping(el, 'Bar');
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
});
it('allows many custom selections when multi-choice when requireOptionMatch is false', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox
name="foo"
multiple-choice
.validators=${[new Required()]}
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>
`)
);
el.requireOptionMatch = false;
await el.updateComplete;
const { _inputNode } = getComboboxMembers(el);
await mimicUserTyping(el, 'Foo');
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.modelValue).to.eql(['Foo']);
expect(el.checkedIndex).to.eql([]);
await mimicUserTyping(el, 'Bar');
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.modelValue).to.eql(['Foo', 'Bar']);
expect(el.checkedIndex).to.eql([]);
});
it('allows new options when multi-choice when requireOptionMatch=false and autocomplete="both", without selecting similar values', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox
name="foo"
multiple-choice
.requireOptionMatch=${false}
autocomplete="both"
>
<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>
`)
);
await el.updateComplete;
const { _inputNode } = getComboboxMembers(el);
await mimicUserTyping(el, 'Artist');
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.modelValue).to.eql(['Artist']);
});
it('allows new options when multi-choice when requireOptionMatch=false and autocomplete="both", when deleting autocomplete values using Backspace', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox
name="foo"
multiple-choice
.requireOptionMatch=${false}
autocomplete="both"
>
<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>
`)
);
await el.updateComplete;
const { _inputNode } = getComboboxMembers(el);
await mimicUserTyping(el, 'Art');
await el.updateComplete;
await mimicUserTypingAdvanced(el, ['Backspace']);
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.modelValue).to.eql(['Art']);
});
it('allows new custom options when multi-choice when requireOptionMatch=false and autocomplete="both", when deleting autocompleted values using Delete', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox
name="foo"
multiple-choice
.requireOptionMatch=${false}
autocomplete="both"
>
<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>
`)
);
await el.updateComplete;
const { _inputNode } = getComboboxMembers(el);
el.modelValue = [];
await mimicUserTyping(el, 'Art');
await el.updateComplete;
await mimicUserTypingAdvanced(el, ['Delete']);
await el.updateComplete;
mimicKeyPress(_inputNode, 'Enter');
await el.updateComplete;
expect(el.modelValue).to.eql(['Art']);
});
it('allows prefilling the combobox', async () => {
const autocompleteValues = ['inline', 'none', 'both', 'list'];
for (const autocompleteValue of autocompleteValues) {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox autocomplete="${autocompleteValue}" .modelValue="${'Chard'}" name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
expect(el.modelValue).to.equal('Chard');
// @ts-expect-error
expect(el._inputNode.value).to.equal('Chard');
}
});
it('submits form on [Enter] when listbox is closed', async () => {
const submitSpy = sinon.spy(e => e.preventDefault());
const el = /** @type {HTMLFormElement} */ (
await fixture(html`
<form @submit=${submitSpy}>
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
<button type="submit">submit</button>
</form>
`)
);
const combobox = /** @type {LionCombobox} */ (el.querySelector('[name="foo"]'));
const { _inputNode } = getComboboxMembers(combobox);
await combobox.updateComplete;
_inputNode.focus();
await sendKeys({
press: 'Enter',
});
expect(submitSpy.callCount).to.equal(1);
});
it('does not submit form on [Enter] when listbox is opened', async () => {
const submitSpy = sinon.spy(e => e.preventDefault());
const el = /** @type {HTMLFormElement} */ (
await fixture(html`
<form @submit=${submitSpy}>
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
<button type="submit">submit</button>
</form>
`)
);
const combobox = /** @type {LionCombobox} */ (el.querySelector('[name="foo"]'));
const { _inputNode } = getComboboxMembers(combobox);
combobox.opened = true;
await combobox.updateComplete;
_inputNode.focus();
await sendKeys({
press: 'Enter',
});
expect(submitSpy.callCount).to.equal(0);
});
});
describe('Overlay visibility', () => {
it('does not show overlay on click', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _comboboxNode } = getComboboxMembers(el);
expect(el.opened).to.equal(false);
_comboboxNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.equal(false);
});
it('does not flash the menu when _showOverlayCondition returns "false"', async () => {
class ComplexCombobox extends LionCombobox {
_showOverlayCondition() {
return false;
}
}
const tagName = defineCE(ComplexCombobox);
const tag = unsafeStatic(tagName);
const el = /** @type {LionCombobox} */ (
await fixture(html`
<${tag} name="combo" label="Display only the label once selected">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</${tag}>
`)
);
const dialog = el.shadowRoot?.querySelector('dialog');
/**
* hasDropdownFlashed is `true` if the menu was shown for a short period of time and then got closed
*/
let hasDropdownFlashed = false;
const observer = new MutationObserver(mutationList => {
// eslint-disable-next-line no-unused-vars
for (const mutation of mutationList) {
if (dialog?.style.display === '') {
hasDropdownFlashed = true;
}
}
});
observer.observe(/** @type {Node} */ (dialog), { attributeFilter: ['style'] });
const { _inputNode } = getComboboxMembers(el);
_inputNode.focus();
await sendKeys({
type: 'art',
});
expect(hasDropdownFlashed).to.be.false;
});
it('shows overlay on click when filled', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _comboboxNode } = getComboboxMembers(el);
el.modelValue = 'Art';
expect(el.opened).to.equal(false);
_comboboxNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.equal(false);
});
it('shows overlay again after select and char keyup', async () => {
/**
* Scenario:
* [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 overlay should show again
*/
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
const { _inputNode } = getComboboxMembers(el);
expect(el.opened).to.equal(false);
// step [1]
_inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.equal(false);
// step [2]
await mimicUserTyping(el, 'c');
await el.updateComplete;
expect(el.opened).to.equal(true);
// step [3]
options[0].click();
await el.updateComplete;
expect(el.opened).to.equal(false);
expect(isActiveElement(_inputNode)).to.be.true;
// step [4]
await el.updateComplete;
await mimicUserTyping(el, 'c');
await el.updateComplete;
expect(el.opened).to.equal(true);
});
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>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
await mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
expect(_inputNode.value).to.equal('Artichoke');
_inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(el.opened).to.equal(false);
expect(_inputNode.value).to.equal('');
});
it('hides overlay on [Enter]', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
await mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
expect(_inputNode.value).to.equal('Artichoke');
// N.B. we do only trigger keydown here (and not mimicKeypress (both keyup and down)),
// because this closely mimics what happens in the browser
_inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.opened).to.equal(false);
expect(_inputNode.value).to.equal('Artichoke');
});
it('hides overlay on [Tab]', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`)
);
const { _inputNode } = getComboboxMembers(el);
await mimicUserTyping(el, 'art');
await el.updateComplete;
expect(el.opened).to.equal(true);
expect(_inputNode.value).to.equal('Artichoke');
// N.B. we do only trigger keydown here (and not mimicKeypress (both keyup a