UNPKG

@lion/ui

Version:

A package of extendable web components

1,316 lines (1,157 loc) 143 kB
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