import '@lion/listbox/define-option'; import { expect, fixture, html, defineCE, unsafeStatic } from '@open-wc/testing'; import sinon from 'sinon'; import '@lion/combobox/define'; import { LionOptions } from '@lion/listbox'; import { browserDetection, LitElement } from '@lion/core'; import { getListboxMembers } from '@lion/listbox/test-helpers'; import { Required } from '@lion/form-core'; import { LionCombobox } from '../src/LionCombobox.js'; /** * @typedef {import('../types/SelectionDisplay').SelectionDisplay} SelectionDisplay * @typedef {import('@lion/listbox/types/ListboxMixinTypes').ListboxHost} ListboxHost * @typedef {import('@lion/form-core/types/FormControlMixinTypes').FormControlHost} FormControlHost */ /** * @param { LionCombobox } el */ function getComboboxMembers(el) { const obj = getListboxMembers(el); return { ...obj, ...{ // @ts-ignore [allow-protected] in test _invokerNode: el._invokerNode, // @ts-ignore [allow-protected] in test _overlayCtrl: el._overlayCtrl, // @ts-ignore [allow-protected] in test _comboboxNode: el._comboboxNode, // @ts-ignore [allow-protected] in test _inputNode: el._inputNode, // @ts-ignore [allow-protected] in test _listboxNode: el._listboxNode, // @ts-ignore [allow-protected] in test _selectionDisplayNode: el._selectionDisplayNode, // @ts-ignore [allow-protected] in test _activeDescendantOwnerNode: el._activeDescendantOwnerNode, // @ts-ignore [allow-protected] in test _ariaVersion: el._ariaVersion, }, }; } /** * @param {LionCombobox} el * @param {string} value */ // TODO: add keys that actually make sense... function mimicUserTyping(el, value) { const { _inputNode } = getComboboxMembers(el); _inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); // eslint-disable-next-line no-param-reassign _inputNode.value = value; _inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); _inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: value })); _inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: value })); } /** * @param {HTMLElement} el * @param {string} key */ function mimicKeyPress(el, key) { el.dispatchEvent(new KeyboardEvent('keydown', { key })); el.dispatchEvent(new KeyboardEvent('keyup', { key })); } /** * @param {LionCombobox} el * @param {string[]} values */ async function mimicUserTypingAdvanced(el, values) { const { _inputNode } = getComboboxMembers(el); _inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); for (const key of values) { // eslint-disable-next-line no-await-in-loop, no-loop-func await new Promise(resolve => { const hasSelection = _inputNode.selectionStart !== _inputNode.selectionEnd; if (key === 'Backspace') { if (hasSelection) { _inputNode.value = _inputNode.value.slice( 0, _inputNode.selectionStart ? _inputNode.selectionStart : undefined, ) + _inputNode.value.slice( _inputNode.selectionEnd ? _inputNode.selectionEnd : undefined, _inputNode.value.length, ); } else { _inputNode.value = _inputNode.value.slice(0, -1); } } else if (hasSelection) { _inputNode.value = _inputNode.value.slice( 0, _inputNode.selectionStart ? _inputNode.selectionStart : undefined, ) + key + _inputNode.value.slice( _inputNode.selectionEnd ? _inputNode.selectionEnd : undefined, _inputNode.value.length, ); } else { _inputNode.value += key; } mimicKeyPress(_inputNode, key); _inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); el.updateComplete.then(() => { // @ts-ignore resolve(); }); }); } } /** * @param {LionCombobox} el */ function getFilteredOptionValues(el) { const options = el.formElements; /** * @param {{ style: { display: string; }; }} option */ const filtered = options.filter(option => option.getAttribute('aria-hidden') !== 'true'); /** * @param {{ value: any; }} option */ return filtered.map(option => option.value); } /** * @param {{ autocomplete?:'none'|'list'|'both', matchMode?:'begin'|'all' }} [config] */ async function fruitFixture({ autocomplete, matchMode } = {}) { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); if (autocomplete) { el.autocomplete = autocomplete; } if (matchMode) { el.matchMode = matchMode; } await el.updateComplete; return [el, el.formElements]; } describe('lion-combobox', () => { 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` Artichoke Chard Chicory Victoria Plum `)); const options = el.formElements; const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true'); async function performChecks() { mimicUserTyping(el, 'c'); await el.updateComplete; expect(visibleOptions().length).to.equal(4); mimicUserTyping(el, ''); await el.updateComplete; expect(visibleOptions().length).to.equal(0); } el.autocomplete = 'none'; await performChecks(); el.autocomplete = 'list'; await performChecks(); el.autocomplete = 'inline'; await performChecks(); el.autocomplete = 'both'; await performChecks(); }); it('hides listbox on click/enter (when multiple-choice is false)', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode, _listboxNode } = getComboboxMembers(el); async function open() { // activate opened listbox _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); 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` Artichoke Chard Chicory Victoria Plum `)); const options = el.formElements; const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true'); async function performChecks() { mimicUserTyping(el, 'c'); await el.updateComplete; expect(visibleOptions().length).to.equal(4); 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 focusin', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode } = getComboboxMembers(el); expect(el.opened).to.be.false; _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); await el.updateComplete; expect(el.opened).to.be.true; }); it('hides overlay on focusin after [Escape]', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode, _inputNode } = getComboboxMembers(el); expect(el.opened).to.be.false; _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); await el.updateComplete; expect(el.opened).to.be.true; _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` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode, _listboxNode, _inputNode } = getComboboxMembers(el); async function open() { // activate opened listbox _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); 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` Item 1 Item 2 `)); const { _listboxNode } = getComboboxMembers(el); expect(_listboxNode).to.exist; expect(_listboxNode).to.be.instanceOf(LionOptions); expect(el.querySelector('[role=listbox]')).to.equal(_listboxNode); }); it('has a textbox element', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 Item 2 `)); const { _comboboxNode } = getComboboxMembers(el); expect(_comboboxNode).to.exist; expect(el.querySelector('[role=combobox]')).to.equal(_comboboxNode); }); }); describe('Values', () => { it('syncs modelValue with textbox', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 Item 2 `)); 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 empty string if no option is selected', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); expect(el.modelValue).to.equal('Artichoke'); expect(el.formElements[0].checked).to.be.true; el.checkedIndex = -1; await el.updateComplete; expect(el.modelValue).to.equal(''); 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` Artichoke Chard Chicory Victoria Plum `)); expect(el.modelValue).to.eql(['Artichoke']); expect(el.formElements[0].checked).to.be.true; el.checkedIndex = []; await el.updateComplete; expect(el.modelValue).to.eql([]); expect(el.formElements[0].checked).to.be.false; }); it('clears modelValue and textbox value on clear()', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); el.clear(); expect(el.modelValue).to.equal(''); expect(_inputNode.value).to.equal(''); const el2 = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); el2.clear(); expect(el2.modelValue).to.eql([]); expect(_inputNode.value).to.equal(''); }); it('syncs textbox to modelValue', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Aha Bhb `)); const { _inputNode } = getComboboxMembers(el); async function performChecks() { el.formElements[0].click(); await el.updateComplete; // FIXME: fix properly for Webkit // expect(_inputNode.value).to.equal('Aha'); expect(el.checkedIndex).to.equal(0); mimicUserTyping(el, 'Ah'); await el.updateComplete; expect(_inputNode.value).to.equal('Ah'); await el.updateComplete; expect(el.checkedIndex).to.equal(-1); } el.autocomplete = 'none'; await performChecks(); el.autocomplete = 'list'; await performChecks(); }); }); describe('Overlay visibility', () => { it('does not show overlay on focusin', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode } = getComboboxMembers(el); expect(el.opened).to.equal(false); _comboboxNode.dispatchEvent(new Event('focusin', { 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` Artichoke Chard Chicory Victoria Plum `)); const options = el.formElements; const { _inputNode } = getComboboxMembers(el); expect(el.opened).to.equal(false); // step [1] _inputNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); await el.updateComplete; expect(el.opened).to.equal(false); // step [2] 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(document.activeElement).to.equal(_inputNode); // step [4] await el.updateComplete; 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` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode, _inputNode } = getComboboxMembers(el); // open _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); 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 [Tab]', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode, _inputNode } = getComboboxMembers(el); // open _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); 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: 'Tab' })); expect(el.opened).to.equal(false); expect(_inputNode.value).to.equal('Artichoke'); }); it('clears checkedIndex on empty text', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode, _inputNode } = getComboboxMembers(el); // open _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); mimicUserTyping(el, 'art'); await el.updateComplete; expect(el.opened).to.equal(true); expect(_inputNode.value).to.equal('Artichoke'); expect(el.checkedIndex).to.equal(0); 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 { /** @param {{ currentValue: string, lastKey:string }} options */ _showOverlayCondition(options) { return this.focused || super._showOverlayCondition(options); } } const tagName = defineCE(ShowOverlayConditionCombobox); const tag = unsafeStatic(tagName); const el = /** @type {LionCombobox} */ (await fixture(html` <${tag} name="foo" multiple-choice> Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode } = getComboboxMembers(el); expect(el.opened).to.equal(false); _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); await el.updateComplete; expect(el.opened).to.equal(true); }); it('allows to control overlay visibility via "_showOverlayCondition": should not display overlay if currentValue length condition is not fulfilled', async () => { class ShowOverlayConditionCombobox extends LionCombobox { /** @param {{ currentValue: string, lastKey:string }} options */ _showOverlayCondition(options) { return options.currentValue.length > 3 && super._showOverlayCondition(options); } } const tagName = defineCE(ShowOverlayConditionCombobox); const tag = unsafeStatic(tagName); const el = /** @type {LionCombobox} */ (await fixture(html` <${tag} name="foo"> Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'aaa'); expect(el.opened).to.be.false; }); it('allows to control overlay visibility via "_showOverlayCondition": should display overlay if currentValue length condition is fulfilled', async () => { class ShowOverlayConditionCombobox extends LionCombobox { /** @param {{ currentValue: string, lastKey:string }} options */ _showOverlayCondition(options) { return options.currentValue.length > 3 && super._showOverlayCondition(options); } } const tagName = defineCE(ShowOverlayConditionCombobox); const tag = unsafeStatic(tagName); const el = /** @type {LionCombobox} */ (await fixture(html` <${tag} name="foo"> Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'aaaa'); expect(el.opened).to.be.true; }); it('allows to control overlay visibility via "_showOverlayCondition": should not display overlay if currentValue length condition is not fulfilled after once fulfilled', async () => { class ShowOverlayConditionCombobox extends LionCombobox { /** @param {{ currentValue: string, lastKey:string }} options */ _showOverlayCondition(options) { return options.currentValue.length > 3 && super._showOverlayCondition(options); } } const tagName = defineCE(ShowOverlayConditionCombobox); const tag = unsafeStatic(tagName); const el = /** @type {LionCombobox} */ (await fixture(html` <${tag} name="foo"> Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'aaaa'); expect(el.opened).to.be.true; mimicUserTyping(el, 'aaa'); await el.updateComplete; expect(el.opened).to.be.false; }); }); describe('Accessibility', () => { it('sets "aria-posinset" and "aria-setsize" on visible entries', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const options = el.formElements; const { _comboboxNode } = getComboboxMembers(el); expect(el.opened).to.equal(false); _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); mimicUserTyping(el, 'art'); await el.updateComplete; expect(el.opened).to.equal(true); const visibleOptions = options.filter(o => o.style.display !== 'none'); visibleOptions.forEach((o, i) => { expect(o.getAttribute('aria-posinset')).to.equal(`${i + 1}`); expect(o.getAttribute('aria-setsize')).to.equal(`${visibleOptions.length}`); }); /** * @param {{ style: { display: string; }; }} o */ const hiddenOptions = options.filter(o => o.style.display === 'none'); /** * @param {{ hasAttribute: (arg0: string) => any; }} o */ hiddenOptions.forEach(o => { expect(o.hasAttribute('aria-posinset')).to.equal(false); expect(o.hasAttribute('aria-setsize')).to.equal(false); }); }); /** * Note that we use aria-hidden instead of 'display:none' to allow for animations * (like fade in/out) */ it('sets aria-hidden="true" on hidden entries', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const options = el.formElements; const { _comboboxNode } = getComboboxMembers(el); expect(el.opened).to.equal(false); _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); mimicUserTyping(el, 'art'); expect(el.opened).to.equal(true); await el.updateComplete; const visibleOptions = options.filter(o => o.style.display !== 'none'); visibleOptions.forEach(o => { expect(o.hasAttribute('aria-hidden')).to.be.false; }); const hiddenOptions = options.filter(o => o.style.display === 'none'); hiddenOptions.forEach(o => { expect(o.getAttribute('aria-hidden')).to.equal('true'); }); }); it('works with validation', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); 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); await el.feedbackComplete; expect(el.hasFeedbackFor).to.include('error'); expect(el.showsFeedbackFor).to.include('error'); }); }); }); // Notice that the LionComboboxInvoker always needs to be used in conjunction with the // LionCombobox, and therefore will be tested integrated, describe('Invoker component integration', () => { describe('Accessibility', () => { it('sets role="combobox" on textbox wrapper/listbox sibling', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 Item 2 `)); const { _comboboxNode } = getComboboxMembers(el); expect(_comboboxNode.getAttribute('role')).to.equal('combobox'); }); it('sets aria-expanded to element with role="combobox" in wai-aria 1.0 and 1.1', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 Item 2 `)); const { _comboboxNode } = getComboboxMembers(el); expect(_comboboxNode.getAttribute('aria-expanded')).to.equal('false'); el.opened = true; await el.updateComplete; expect(_comboboxNode.getAttribute('aria-expanded')).to.equal('true'); const el2 = /** @type {LionCombobox} */ (await fixture(html` Item 1 Item 2 `)); const { _comboboxNode: comboboxNode2 } = getComboboxMembers(el2); expect(comboboxNode2.getAttribute('aria-expanded')).to.equal('false'); el2.opened = true; await el2.updateComplete; expect(_comboboxNode.getAttribute('aria-expanded')).to.equal('true'); }); it('makes sure listbox node is not focusable', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 Item 2 `)); const { _listboxNode } = getComboboxMembers(el); expect(_listboxNode.hasAttribute('tabindex')).to.be.false; }); }); }); describe('Selection display', () => { class MySelectionDisplay extends LitElement { /** * @param {import('@lion/core').PropertyValues } changedProperties */ onComboboxElementUpdated(changedProperties) { if ( changedProperties.has('modelValue') && // @ts-ignore this.comboboxElement.multipleChoice ) { // do smth.. } } } const selectionDisplayTag = unsafeStatic(defineCE(MySelectionDisplay)); it('stores internal reference _selectionDisplayNode in LionCombobox', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` <${selectionDisplayTag} slot="selection-display"> Item 1 `)); const { _selectionDisplayNode } = getComboboxMembers(el); expect(_selectionDisplayNode).to.equal(el.querySelector('[slot=selection-display]')); }); it('sets a reference to combobox element in _selectionDisplayNode', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` <${selectionDisplayTag} slot="selection-display"> Item 1 `)); // @ts-ignore allow protected members expect(el._selectionDisplayNode.comboboxElement).to.equal(el); }); it('calls "onComboboxElementUpdated(changedProperties)" on "updated" in _selectionDisplayNode', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` <${selectionDisplayTag} slot="selection-display"> Item 1 `)); // @ts-ignore sinon type error const spy = sinon.spy(el._selectionDisplayNode, 'onComboboxElementUpdated'); el.requestUpdate('modelValue'); await el.updateComplete; expect(spy).to.have.been.calledOnce; }); // TODO: put those in distinct file if ./docs/lion-combobox-selection-display.js is accessible // and exposable describe.skip('Selected chips display', () => { // it('displays chips next to textbox, ordered based on user selection', async () => { // const el = /** @type {LionCombobox} */ (await fixture(html` // // Artichoke // Chard // Chicory // Victoria Plum // // `)); // const options = el.formElements; // options[2].checked = true; // Chicory // options[0].checked = true; // Artichoke // options[1].checked = true; // Chard // const chips = Array.from(el._comboboxNode.querySelectorAll('.selection-chip')); // expect(chips.map(elm => elm.textContent)).to.eql(['Chicory', 'Artichoke', 'Chard']); // expect(el._comboboxNode.selectedElements).to.eql([options[2], options[0], options[1]]); // }); // it('stages deletable chips on [Backspace]', async () => { // const el = /** @type {LionCombobox} */ (await fixture(html` // // Artichoke // Chard // Chicory // // `)); // const options = el.formElements; // options[0].checked = true; // Artichoke // options[1].checked = true; // Chard // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.false; // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.true; // el._inputNode.blur(); // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.false; // }); // it('deletes staged chip on [Backspace]', async () => { // const el = /** @type {LionCombobox} */ (await fixture(html` // // Artichoke // Chard // Chicory // // `)); // const options = el.formElements; // options[0].checked = true; // Artichoke // options[1].checked = true; // Chard // expect(el._comboboxNode.selectedElements).to.eql([options[0], options[1]]); // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.false; // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); // expect(el._comboboxNode.selectedElements).to.eql([options[0], options[1]]); // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.true; // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); // expect(el._comboboxNode.selectedElements).to.eql([options[0]]); // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.true; // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); // expect(el._comboboxNode.selectedElements).to.eql([]); // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.true; // el._inputNode.blur(); // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.false; // }); }); }); describe('Autocompletion', () => { it('has autocomplete "both" by default', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 `)); expect(el.autocomplete).to.equal('both'); }); it('filters options when autocomplete is "both"', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(getFilteredOptionValues(el)).to.eql(['Artichoke', 'Chard', 'Chicory']); }); it('completes textbox when autocomplete is "both"', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'ch'); await el.updateComplete; const { _inputNode } = getComboboxMembers(el); expect(_inputNode.value).to.equal('Chard'); expect(_inputNode.selectionStart).to.equal(2); expect(_inputNode.selectionEnd).to.equal(_inputNode.value.length); // We don't autocomplete when characters are removed mimicUserTyping(el, 'c'); // The user pressed backspace (number of chars decreased) expect(_inputNode.value).to.equal('c'); expect(_inputNode.selectionStart).to.equal(_inputNode.value.length); }); it('filters options when autocomplete is "list"', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(getFilteredOptionValues(el)).to.eql(['Artichoke', 'Chard', 'Chicory']); expect(_inputNode.value).to.equal('ch'); }); it('does not filter options when autocomplete is "none"', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(getFilteredOptionValues(el)).to.eql([ 'Artichoke', 'Chard', 'Chicory', 'Victoria Plum', ]); }); it('does not filter options when autocomplete is "inline"', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(getFilteredOptionValues(el)).to.eql([ 'Artichoke', 'Chard', 'Chicory', 'Victoria Plum', ]); }); it('resets "checkedIndex" when going from matched to unmatched textbox value', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(el.checkedIndex).to.equal(1); mimicUserTyping(el, 'cho'); await el.updateComplete; expect(el.checkedIndex).to.equal(-1); // Works for autocomplete 'both' as well. const el2 = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el2, 'ch'); await el2.updateComplete; expect(el2.checkedIndex).to.equal(1); // Also works when 'diminishing amount of chars' mimicUserTyping(el2, 'x'); await el2.updateComplete; expect(el2.checkedIndex).to.equal(-1); }); it('completes chars inside textbox', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(_inputNode.value).to.equal('Chard'); expect(_inputNode.selectionStart).to.equal('ch'.length); expect(_inputNode.selectionEnd).to.equal('Chard'.length); await mimicUserTypingAdvanced(el, ['i', 'c']); await el.updateComplete; expect(_inputNode.value).to.equal('Chicory'); expect(_inputNode.selectionStart).to.equal('chic'.length); expect(_inputNode.selectionEnd).to.equal('Chicory'.length); // Diminishing chars, but autocompleting mimicUserTyping(el, 'ch'); await el.updateComplete; expect(_inputNode.value).to.equal('ch'); expect(_inputNode.selectionStart).to.equal('ch'.length); expect(_inputNode.selectionEnd).to.equal('ch'.length); }); it('synchronizes textbox on overlay close', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); expect(_inputNode.value).to.equal(''); /** * @param {'none' | 'list' | 'inline' | 'both'} autocomplete * @param {number|number[]} index * @param {string} valueOnClose */ 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(_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], ''); await performChecks('list', [0, 1], ''); await performChecks('inline', [0, 1], ''); await performChecks('both', [0, 1], ''); }); it('is possible to adjust textbox synchronize condition on overlay close', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); expect(_inputNode.value).to.equal(''); /** * @param {'none' | 'list' | 'inline' | 'both'} autocomplete * @param {number|number[]} index * @param {string} valueOnClose */ 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(_inputNode.value).to.equal(valueOnClose); } await performChecks('none', 0, ''); await performChecks('list', 0, ''); await performChecks('inline', 0, ''); await performChecks('both', 0, ''); el.multipleChoice = true; await performChecks('none', [0, 1], ''); await performChecks('list', [0, 1], ''); await performChecks('inline', [0, 1], ''); await performChecks('both', [0, 1], ''); }); it('does inline autocompletion when adding chars', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'ch'); // ch await el.updateComplete; // Ch[ard] expect(el.activeIndex).to.equal(1); expect(el.checkedIndex).to.equal(1); await mimicUserTypingAdvanced(el, ['i', 'c']); // Chic await el.updateComplete; // Chic[ory] expect(el.activeIndex).to.equal(2); expect(el.checkedIndex).to.equal(2); await mimicUserTypingAdvanced(el, ['Backspace', 'Backspace', 'Backspace', 'Backspace', 'h']); // Ch await el.updateComplete; // Ch[ard] expect(el.activeIndex).to.equal(1); expect(el.checkedIndex).to.equal(1); }); it('does inline autocompletion when changing the word', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(el.activeIndex).to.equal(1); expect(el.checkedIndex).to.equal(1); await mimicUserTypingAdvanced(el, ['i']); await el.updateComplete; expect(el.activeIndex).to.equal(2); expect(el.checkedIndex).to.equal(2); // Diminishing chars, but autocompleting mimicUserTyping(el, 'a'); await el.updateComplete; expect(el.activeIndex).to.equal(0); expect(el.checkedIndex).to.equal(0); }); it('computation of "user intends autofill" works correctly afer autofill', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(_inputNode.value).to.equal('Chard'); expect(_inputNode.selectionStart).to.equal('Ch'.length); expect(_inputNode.selectionEnd).to.equal('Chard'.length); // Autocompletion happened. When we go backwards ('Ch[ard]' => 'Ch'), we should not // autocomplete to 'Chard' anymore. await mimicUserTypingAdvanced(el, ['Backspace']); await el.updateComplete; expect(_inputNode.value).to.equal('Ch'); // so not 'Chard' expect(_inputNode.selectionStart).to.equal('Ch'.length); expect(_inputNode.selectionEnd).to.equal('Ch'.length); }); describe('Server side completion support', () => { const listboxData = ['lorem', 'ipsum', 'dolor', 'sit', 'amet']; class MyEl extends LitElement { constructor() { super(); /** @type {string[]} */ this.options = [...listboxData]; } clearOptions() { /** @type {string[]} */ this.options = []; this.requestUpdate(); } addOption() { this.options.push(`option ${this.options.length + 1}`); this.requestUpdate(); } get combobox() { return /** @type {LionCombobox} */ (this.shadowRoot?.querySelector('#combobox')); } render() { return html` ${this.options.map( option => html` ${option} `, )} `; } } const tagName = defineCE(MyEl); const wrappingTag = unsafeStatic(tagName); it('calls "_handleAutocompletion" after externally changing options', async () => { const el = /** @type {MyEl} */ (await fixture(html`<${wrappingTag}>`)); await el.combobox.registrationComplete; // @ts-ignore [allow-protected] in test const spy = sinon.spy(el.combobox, '_handleAutocompletion'); el.addOption(); await el.updateComplete; await el.updateComplete; expect(spy).to.have.been.calledOnce; el.clearOptions(); await el.updateComplete; await el.updateComplete; expect(spy).to.have.been.calledTwice; }); }); describe('Subclassers', () => { it('allows to configure autoselect', async () => { class X extends LionCombobox { _autoSelectCondition() { return true; } } const tagName = defineCE(X); const tag = unsafeStatic(tagName); const el = /** @type {LionCombobox} */ (await fixture(html` <${tag} name="foo" autocomplete="list" opened> Artichoke Chard Chicory Victoria Plum `)); // This ensures autocomplete would be off originally el.autocomplete = 'list'; await mimicUserTypingAdvanced(el, ['v', 'i']); // so we have options ['Victoria Plum'] await el.updateComplete; expect(el.checkedIndex).to.equal(3); }); }); it('highlights matching options', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const options = el.formElements; mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); await el.updateComplete; expect(options[0]).lightDom.to.equal(`Artichoke`); expect(options[1]).lightDom.to.equal(`Chard`); expect(options[2]).lightDom.to.equal(`Chicory`); expect(options[3]).lightDom.to.equal(`Victoria Plum`); mimicUserTyping(/** @type {LionCombobox} */ (el), 'D'); await el.updateComplete; expect(options[0]).lightDom.to.equal(`Artichoke`); expect(options[1]).lightDom.to.equal(`Chard`); expect(options[2]).lightDom.to.equal(`Chicory`); expect(options[3]).lightDom.to.equal(`Victoria Plum`); }); it('synchronizes textbox when autocomplete is "inline" or "both"', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); expect(_inputNode.value).to.equal(''); el.setCheckedIndex(-1); el.autocomplete = 'none'; el.setCheckedIndex(0); expect(_inputNode.value).to.equal(''); el.setCheckedIndex(-1); el.autocomplete = 'list'; el.setCheckedIndex(0); expect(_inputNode.value).to.equal(''); el.setCheckedIndex(-1); el.autocomplete = 'inline'; el.setCheckedIndex(0); expect(_inputNode.value).to.equal('Artichoke'); el.setCheckedIndex(-1); el.autocomplete = 'both'; el.setCheckedIndex(0); expect(_inputNode.value).to.equal('Artichoke'); }); it('synchronizes last index to textbox when autocomplete is "inline" or "both" when multipleChoice', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); expect(_inputNode.value).to.eql(''); el.setCheckedIndex(-1); el.autocomplete = 'none'; el.setCheckedIndex([0]); el.setCheckedIndex([1]); expect(_inputNode.value).to.equal(''); el.setCheckedIndex(-1); el.autocomplete = 'list'; el.setCheckedIndex([0]); el.setCheckedIndex([1]); expect(_inputNode.value).to.equal(''); el.setCheckedIndex(-1); el.autocomplete = 'inline'; el.setCheckedIndex([0]); expect(_inputNode.value).to.equal('Artichoke'); el.setCheckedIndex([1]); expect(_inputNode.value).to.equal('Chard'); el.setCheckedIndex(-1); el.autocomplete = 'both'; el.setCheckedIndex([0]); expect(_inputNode.value).to.equal('Artichoke'); el.setCheckedIndex([1]); expect(_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; } /** * @param {?} modelValue * @param {?} oldModelValue */ // eslint-disable-next-line no-unused-vars _syncToTextboxMultiple(modelValue, oldModelValue) { // In a real scenario (depending on how selection display works), // you could override the default (last selected option) with '' for instance this._setTextboxValue(`${modelValue}-${oldModelValue}-multi`); } } const tagName = defineCE(X); const tag = unsafeStatic(tagName); const el = /** @type {LionCombobox} */ (await fixture(html` <${tag} name="foo" autocomplete="none" multiple-choice> Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); el.setCheckedIndex(-1); el.autocomplete = 'none'; el.setCheckedIndex([0]); expect(_inputNode.value).to.equal('Artichoke--multi'); el.setCheckedIndex(-1); el.autocomplete = 'list'; el.setCheckedIndex([0]); expect(_inputNode.value).to.equal('Artichoke--multi'); el.setCheckedIndex(-1); el.autocomplete = 'inline'; el.setCheckedIndex([0]); expect(_inputNode.value).to.equal('Artichoke--multi'); el.setCheckedIndex(-1); el.autocomplete = 'both'; el.setCheckedIndex([0]); expect(_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` Artichoke Chard Chicory Victoria Plum `)); mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; expect(el.activeIndex).to.equal(1); }); it('changes whether activeIndex is set to the closest match automatically depending on autocomplete', async () => { /** * Automatic selection (setting activeIndex to closest matching option) in lion is set for * 'inline' & 'both' autocomplete, because it is unavoidable there * For 'list' & 'none' autocomplete, it is turned off and manual selection is required. * TODO: Make this configurable for list & none autocomplete? */ const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); /** * @param {LionCombobox} elm * @param {'none'|'list'|'inline'|'both'} autocomplete */ async function setup(elm, autocomplete) { // eslint-disable-next-line no-param-reassign elm.autocomplete = autocomplete; // eslint-disable-next-line no-param-reassign elm.activeIndex = -1; // eslint-disable-next-line no-param-reassign elm.checkedIndex = -1; // eslint-disable-next-line no-param-reassign elm.opened = true; await elm.updateComplete; } // 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 await setup(el, 'none'); mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; expect(el.activeIndex).to.equal(-1); expect(el.opened).to.be.true; mimicKeyPress(_inputNode, 'Enter'); expect(el.opened).to.be.false; expect(el.activeIndex).to.equal(-1); // 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 await setup(el, 'list'); mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; expect(el.opened).to.be.true; expect(el.activeIndex).to.equal(-1); mimicKeyPress(_inputNode, 'Enter'); 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 (mostly, but with aria-autocomplete="inline") await setup(el, 'inline'); mimicUserTyping(/** @type {LionCombobox} */ (el), ''); await el.updateComplete; mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; await el.updateComplete; expect(el.opened).to.be.true; expect(el.activeIndex).to.equal(1); mimicKeyPress(_inputNode, '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 await setup(el, 'both'); mimicUserTyping(/** @type {LionCombobox} */ (el), ''); await el.updateComplete; mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; mimicKeyPress(_inputNode, 'Enter'); expect(el.activeIndex).to.equal(1); expect(el.opened).to.be.false; }); it('sets the active index to the closest match on autocomplete', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; expect(el.activeIndex).to.equal(1); mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); await el.updateComplete; mimicUserTyping(/** @type {LionCombobox} */ (el), 'chi'); // Chard no longer matches, so should switch active to Chicory await el.updateComplete; expect(el.activeIndex).to.equal(2); // select artichoke mimicUserTyping(/** @type {LionCombobox} */ (el), 'artichoke'); await el.updateComplete; mimicKeyPress(_inputNode, 'Enter'); mimicUserTyping(/** @type {LionCombobox} */ (el), ''); await el.updateComplete; // change selection, active index should update to closest match mimicUserTyping(/** @type {LionCombobox} */ (el), 'vic'); await el.updateComplete; expect(el.activeIndex).to.equal(3); }); it('supports clearing by [Escape] key and resets active state on all options', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _inputNode } = getComboboxMembers(el); // Select something mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; mimicKeyPress(_inputNode, 'Enter'); expect(el.activeIndex).to.equal(1); mimicKeyPress(_inputNode, 'Escape'); await el.updateComplete; expect(_inputNode.textContent).to.equal(''); el.formElements.forEach(option => expect(option.active).to.be.false); // change selection, active index should update to closest match mimicUserTyping(/** @type {LionCombobox} */ (el), 'vic'); await el.updateComplete; expect(el.activeIndex).to.equal(3); }); }); describe('Accessibility', () => { it('synchronizes autocomplete option to textbox', async () => { let el; [el] = await fruitFixture({ autocomplete: 'both' }); expect( getComboboxMembers(/** @type {LionCombobox} */ (el))._inputNode.getAttribute( 'aria-autocomplete', ), ).to.equal('both'); [el] = await fruitFixture({ autocomplete: 'list' }); expect( getComboboxMembers(/** @type {LionCombobox} */ (el))._inputNode.getAttribute( 'aria-autocomplete', ), ).to.equal('list'); [el] = await fruitFixture({ autocomplete: 'none' }); expect( getComboboxMembers(/** @type {LionCombobox} */ (el))._inputNode.getAttribute( 'aria-autocomplete', ), ).to.equal('none'); }); it('updates aria-activedescendant on textbox node', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const elProts = getComboboxMembers(el); expect(elProts._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( null, ); expect(el.formElements[1].active).to.equal(false); mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); await el.updateComplete; expect(elProts._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( null, ); // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); mimicKeyPress(elProts._inputNode, 'ArrowDown'); expect(elProts._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( 'artichoke-option', ); expect(el.formElements[1].active).to.equal(false); const el2 = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const el2Prots = getComboboxMembers(el2); mimicUserTyping(/** @type {LionCombobox} */ (el2), 'ch'); await el2.updateComplete; expect(el2Prots._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( el2.formElements[1].id, ); expect(el2.formElements[1].active).to.equal(true); el2.autocomplete = 'list'; mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); await el2.updateComplete; expect(el2Prots._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( el2.formElements[1].id, ); expect(el2.formElements[1].active).to.equal(true); }); it('adds aria-label to highlighted options', async () => { const [el, options] = await fruitFixture({ autocomplete: 'both', matchMode: 'all' }); mimicUserTyping(/** @type {LionCombobox} */ (el), 'choke'); await el.updateComplete; const labelledElement = options[0].querySelector('span[aria-label="Artichoke"]'); expect(labelledElement).to.not.be.null; expect(labelledElement.innerText).to.equal('Artichoke'); }); }); }); describe('Accessibility', () => { describe('Aria versions', () => { it('[role=combobox] wraps input node in v1.1', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 `)); const { _comboboxNode, _inputNode } = getComboboxMembers(el); expect(_comboboxNode.contains(_inputNode)).to.be.true; }); it('has one input node with [role=combobox] in v1.0', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 `)); const { _comboboxNode, _inputNode } = getComboboxMembers(el); expect(_comboboxNode).to.equal(_inputNode); }); it('autodetects aria version and sets it to 1.1 on Chromium browsers', async () => { const browserDetectionIsChromiumOriginal = browserDetection.isChromium; browserDetection.isChromium = true; const el = /** @type {LionCombobox} */ (await fixture(html` Item 1 `)); const elProts = getComboboxMembers(el); expect(elProts._ariaVersion).to.equal('1.1'); browserDetection.isChromium = false; const el2 = /** @type {LionCombobox} */ (await fixture(html` Item 1 `)); const el2Prots = getComboboxMembers(el2); expect(el2Prots._ariaVersion).to.equal('1.0'); // restore... browserDetection.isChromium = browserDetectionIsChromiumOriginal; }); }); }); describe('Multiple Choice', () => { // TODO: possibly later share test with select-rich if it officially supports multipleChoice it('does not close listbox on click/enter', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke Chard Chicory Victoria Plum `)); const { _comboboxNode } = getComboboxMembers(el); // activate opened listbox _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); mimicUserTyping(el, 'ch'); await el.updateComplete; expect(el.opened).to.equal(true); const visibleOptions = el.formElements.filter(o => o.style.display !== 'none'); visibleOptions[0].click(); expect(el.opened).to.equal(true); mimicKeyPress(visibleOptions[1], 'Enter'); expect(el.opened).to.equal(true); }); }); describe('Match Mode', () => { it('has a default value of "all"', async () => { const [el] = await fruitFixture(); expect(el.matchMode).to.equal('all'); }); it('will suggest partial matches (in the middle of the word) when set to "all"', async () => { const [el] = await fruitFixture(); mimicUserTyping(/** @type {LionCombobox} */ (el), 'c'); await el.updateComplete; expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql([ 'Artichoke', 'Chard', 'Chicory', 'Victoria Plum', ]); }); it('will only suggest beginning matches when set to "begin"', async () => { const [el] = await fruitFixture({ matchMode: 'begin' }); mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); await el.updateComplete; expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql([ 'Chard', 'Chicory', ]); }); describe('Subclassers', () => { it('allows for custom matching functions', async () => { const [el] = await fruitFixture(); /** * @param {{ value: any; }} option * @param {any} curValue */ function onlyExactMatches(option, curValue) { return option.value === curValue; } el.matchCondition = onlyExactMatches; mimicUserTyping(/** @type {LionCombobox} */ (el), 'Chicory'); await el.updateComplete; expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql(['Chicory']); mimicUserTyping(/** @type {LionCombobox} */ (el), 'Chicor'); await el.updateComplete; expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql([]); }); }); }); });