UNPKG

@lion/ui

Version:

A package of extendable web components

1,203 lines (1,050 loc) 69.8 kB
import { LitElement } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { Required } from '@lion/ui/form-core.js'; import { LionOptions } from '@lion/ui/listbox.js'; import '@lion/ui/define/lion-listbox.js'; import '@lion/ui/define/lion-option.js'; import { aTimeout, defineCE, expect, fixture as _fixture, html, nextFrame, unsafeStatic, } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import { getListboxMembers } from '../../../exports/listbox-test-helpers.js'; import { browserDetection } from '../../core/src/browserDetection.js'; /** * @typedef {import('../src/LionListbox.js').LionListbox} LionListbox * @typedef {import('../src/LionOption.js').LionOption} LionOption * @typedef {import('../../select-rich/src/LionSelectInvoker.js').LionSelectInvoker} LionSelectInvoker * @typedef {import('lit').TemplateResult} TemplateResult */ const fixture = /** @type {(arg: TemplateResult) => Promise<LionListbox>} */ (_fixture); /** * @param {HTMLElement} el * @param {string} key * @param {string} code */ function mimicKeyPress(el, key, code = '') { el.dispatchEvent(new KeyboardEvent('keydown', { key, code })); el.dispatchEvent(new KeyboardEvent('keyup', { key, code })); } /** * @param { {tagString?:string, optionTagString?:string} } [customConfig] */ export function runListboxMixinSuite(customConfig = {}) { const cfg = { tagString: 'lion-listbox', optionTagString: 'lion-option', ...customConfig, }; const tag = unsafeStatic(cfg.tagString); const optionTag = unsafeStatic(cfg.optionTagString); describe('ListboxMixin', () => { // TODO: See if it is possible to get functionality from ChoiceGroup and/or Field suite(s) describe('FormControl (Field / ChoiceGroup) functionality', () => { it('has a single modelValue representing the currently checked option', async () => { const el = await fixture(html` <${tag} name="foo"> <${optionTag} .choiceValue=${'10'} checked>Item 1</${optionTag}> <${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}> </${tag}> `); expect(el.modelValue).to.equal('10'); }); it('should dispatch model-value-changed 1 time on first paint', async () => { const spy = sinon.spy(); await fixture(html` <${tag} @model-value-changed="${spy}"> <${optionTag} .choiceValue="${'10'}">Item 1</${optionTag}> <${optionTag} .choiceValue="${'20'}">Item 2</${optionTag}> <${optionTag} .choiceValue="${'30'}">Item 3</${optionTag}> </${tag}> `); expect(spy.callCount).to.equal(1); }); it('should dispatch model-value-changed 1 time on interaction', async () => { const spy = sinon.spy(); const el = await fixture(html` <${tag}> <${optionTag} .choiceValue="${'10'}">Item 1</${optionTag}> <${optionTag} .choiceValue="${'20'}">Item 2</${optionTag}> <${optionTag} .choiceValue="${'30'}">Item 3</${optionTag}> </${tag}> `); el.addEventListener('model-value-changed', spy); el.formElements[1].checked = true; expect(spy.callCount).to.equal(1); spy.resetHistory(); el.formElements[2].checked = true; expect(spy.callCount).to.equal(1); }); it('should not dispatch model-value-changed on reappend checked child', async () => { const spy = sinon.spy(); const el = await fixture(html` <${tag}> <${optionTag} .choiceValue="${'10'}">Item 1</${optionTag}> <${optionTag} .choiceValue="${'20'}">Item 2</${optionTag}> <${optionTag} .choiceValue="${'30'}">Item 3</${optionTag}> </${tag}> `); el.addEventListener('model-value-changed', spy); el.formElements[1].checked = true; expect(spy.callCount).to.equal(1); el.appendChild(el.formElements[1]); expect(spy.callCount).to.equal(1); }); it('automatically sets the name attribute of child checkboxes to its own name', async () => { const el = await fixture(html` <${tag} name="foo"> <${optionTag} .choiceValue=${10} checked>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> </${tag}> `); expect(el.formElements[0].name).to.equal('foo'); expect(el.formElements[1].name).to.equal('foo'); const validChild = await fixture( html` <${optionTag} .choiceValue=${30}>Item 3</${optionTag}> `, ); el.appendChild(validChild); expect(el.formElements[2].name).to.equal('foo'); }); it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => { const el = await fixture(html` <${tag} name="foo"> <${optionTag} .choiceValue=${10} checked>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> </${tag}> `); const invalidChild = await fixture( html` <${optionTag} .modelValue=${'Lara'}></${optionTag}> `, ); expect(() => { el.addFormElement(invalidChild); }).to.throw( `The ${cfg.tagString} name="foo" does not allow to register lion-option with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }`, ); }); it('can set initial modelValue on creation', async () => { const el = await fixture(html` <${tag} name="gender" .modelValue=${'other'}> <${optionTag} .choiceValue=${'male'}></${optionTag}> <${optionTag} .choiceValue=${'female'}></${optionTag}> <${optionTag} .choiceValue=${'other'}></${optionTag}> </${tag}> `); expect(el.modelValue).to.equal('other'); expect(el.formElements[2].checked).to.be.true; }); it('requests update for modelValue when checkedIndex changes', async () => { const el = /** @type {LionListbox} */ await fixture(html` <${tag} name="gender" .modelValue=${'other'}> <${optionTag} .choiceValue=${'male'}></${optionTag}> <${optionTag} .choiceValue=${'female'}></${optionTag}> <${optionTag} .choiceValue=${'other'}></${optionTag}> </${tag}> `); expect(el.checkedIndex).to.equal(2); const requestSpy = sinon.spy(el, 'requestUpdate'); const updatedSpy = sinon.spy(el, 'updated'); el.setCheckedIndex(1); await el.updateComplete; expect(requestSpy).to.have.been.calledWith('modelValue', 'other'); expect(updatedSpy).to.have.been.calledWith( sinon.match.map.contains(new Map([['modelValue', 'other']])), ); }); it('requests update for modelValue after click', async () => { const el = await fixture(html` <${tag} name="gender" .modelValue=${'other'}> <${optionTag} .choiceValue=${'male'}></${optionTag}> <${optionTag} .choiceValue=${'female'}></${optionTag}> <${optionTag} .choiceValue=${'other'}></${optionTag}> </${tag}> `); expect(el.checkedIndex).to.equal(2); const requestSpy = sinon.spy(el, 'requestUpdate'); const updatedSpy = sinon.spy(el, 'updated'); el.formElements[0].click(); await el.updateComplete; expect(requestSpy).to.have.been.calledWith('modelValue', 'other'); expect(updatedSpy).to.have.been.calledWith( sinon.match.map.contains(new Map([['modelValue', 'other']])), ); }); it('requests update for modelValue when checkedIndex changes for multiple choice', async () => { const el = await fixture(html` <${tag} name="gender" multiple-choice .modelValue=${['other']}> <${optionTag} .choiceValue=${'male'}></${optionTag}> <${optionTag} .choiceValue=${'female'}></${optionTag}> <${optionTag} .choiceValue=${'other'}></${optionTag}> </${tag}> `); expect(el.checkedIndex).to.eql([2]); const requestSpy = sinon.spy(el, 'requestUpdate'); const updatedSpy = sinon.spy(el, 'updated'); el.setCheckedIndex(1); await el.updateComplete; expect(requestSpy).to.have.been.calledWith( 'modelValue', sinon.match.array.deepEquals(['other']), ); expect(updatedSpy).to.have.been.calledOnce; // reference values vs real values suck :( had to do it like this, sinon matchers did not match because 'other' is inside an array so it's not a "real" match expect([...updatedSpy.args[0][0].entries()]).to.deep.include(['modelValue', ['other']]); }); it('requests update for modelValue after click for multiple choice', async () => { const el = await fixture(html` <${tag} name="gender" multiple-choice .modelValue=${['other']}> <${optionTag} .choiceValue=${'male'}></${optionTag}> <${optionTag} .choiceValue=${'female'}></${optionTag}> <${optionTag} .choiceValue=${'other'}></${optionTag}> </${tag}> `); expect(el.checkedIndex).to.eql([2]); const requestSpy = sinon.spy(el, 'requestUpdate'); const updatedSpy = sinon.spy(el, 'updated'); el.formElements[0].click(); await el.updateComplete; expect(requestSpy).to.have.been.calledWith( 'modelValue', sinon.match.array.deepEquals(['other']), ); expect(updatedSpy).to.have.been.calledOnce; // reference values vs real values suck :( had to do it like this, sinon matchers did not match because 'other' is inside an array so it's not a "real" match expect([...updatedSpy.args[0][0].entries()]).to.deep.include(['modelValue', ['other']]); }); it(`has a fieldName based on the label`, async () => { const el1 = await fixture(html` <${tag} label="foo"></${tag}> `); const { _labelNode: _labelNode1 } = getListboxMembers(el1); expect(el1.fieldName).to.equal(_labelNode1.textContent); const el2 = await fixture(html` <${tag}> <label slot="label">bar</label> </${tag}> `); const { _labelNode: _labelNode2 } = getListboxMembers(el2); expect(el2.fieldName).to.equal(_labelNode2.textContent); }); it(`has a fieldName based on the name if no label exists`, async () => { const el = await fixture(html` <${tag} name="foo"></${tag}> `); expect(el.fieldName).to.equal(el.name); }); it(`can override fieldName`, async () => { const el = await fixture(html` <${tag} label="foo" .fieldName="${'bar'}"></${tag}> `); // @ts-ignore [allow-protected] in test expect(el.__fieldName).to.equal(el.fieldName); }); it('supports validation', async () => { const el = await fixture(html` <${tag} id="color" name="color" label="Favorite color" .validators="${[new Required()]}" > <${optionTag} .choiceValue=${null}>select a color</${optionTag}> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}> <${optionTag} .choiceValue=${'hotpink'} disabled>Hotpink</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}> </${tag}> `); expect(el.hasFeedbackFor.includes('error')).to.be.true; expect(el.showsFeedbackFor.includes('error')).to.be.false; // test submitted prop explicitly, since we dont extend field, we add the prop manually el.submitted = true; await el.updateComplete; expect(el.showsFeedbackFor.includes('error')).to.be.true; el.formElements[1].checked = true; await el.updateComplete; expect(el.hasFeedbackFor.includes('error')).to.be.false; expect(el.showsFeedbackFor.includes('error')).to.be.false; el.formElements[0].checked = true; await el.updateComplete; expect(el.hasFeedbackFor.includes('error')).to.be.true; expect(el.showsFeedbackFor.includes('error')).to.be.true; }); }); describe('Selection', () => { it('supports changing the selection through serializedValue setter', async () => { const el = await fixture(html` <${tag} id="color" name="color" label="Favorite color" has-no-default-selected> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}> <${optionTag} .choiceValue=${'hotpink'}>Hotpink</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}> </${tag}> `); expect(el.checkedIndex).to.equal(-1); expect(el.serializedValue).to.equal(''); el.serializedValue = 'hotpink'; expect(el.checkedIndex).to.equal(1); expect(el.serializedValue).to.equal('hotpink'); }); it('scrolls active element into view when necessary, takes into account sticky/fixed elements', async () => { const el = await fixture(html` <div id="scrolling-element" style="position: relative; overflow-y: scroll; height: 570px;"> <div style="position: sticky; top: 0px; width: 100%; height: 100px; background-color: purple; z-index: 1;">Header 1</div> <div style="position: relative"> <div style="position: sticky; top: 100px; width: 100%; height: 50px; background-color: beige; z-index: 1;">Header 2</div> <${tag} id="color" name="color" has-no-default-selected> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'red'}>Red</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'hotpink'}>Hotpink</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'teal'}>Teal</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'1'}>1</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'2'}>2</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'3'}>3</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'4'}>4</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'5'}>5</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'5'}>6</${optionTag}> <${optionTag} style="height: 50px; scroll-margin-top: 150px;" .choiceValue=${'6'}>7</${optionTag}> </${tag}> </div> </div> `); const listboxEl = /** @type {LionListbox} */ (el.querySelector('#color')); // Skip test if listbox cannot receive focus, e.g. in combobox // Skip test if the listbox is controlled by overlay system, // since these overlays "overlay" any fixed/sticky items, meaning // the active item will not be hidden by them. // @ts-ignore allow protected members in tests if (listboxEl._listboxReceivesNoFocus || listboxEl._overlayCtrl) { return; } Object.defineProperty(listboxEl, '_scrollTargetNode', { get: () => el, }); const thirdOption = /** @type {LionOption} */ (listboxEl.formElements[2]); const lastOption = /** @type {LionOption} */ ( listboxEl.formElements[listboxEl.formElements.length - 1] ); await listboxEl.updateComplete; await nextFrame(); // Scroll to last option and wait for browser scroll animation (works) lastOption.active = true; await aTimeout(1000); thirdOption.active = true; await aTimeout(1000); // top should be offset 2x40px (sticky header elems) instead of 0px if (browserDetection.isChrome || browserDetection.isChromium) { // TODO: find out why this is different in recent Chromium expect(el.scrollTop).to.equal(160); } else { expect(el.scrollTop).to.equal(116); } }); }); describe('Accessibility', () => { it('[axe]: is accessible when opened', async () => { const el = await fixture(html` <${tag} label="age" opened> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> </${tag}> `); await el.updateComplete; await el.updateComplete; // need 2 awaits as overlay.show is an async function // for more info about why we need the ignoreRules, see: https://lion.js.org/fundamentals/systems/overlays/rationale/#considerations await expect(el).to.be.accessible({ ignoredRules: ['aria-allowed-role'] }); }); // NB: regular listbox is always 'opened', but needed for combobox and select-rich it('[axe]: is accessible when closed', async () => { const el = await fixture(html` <${tag} label="age"> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> </${tag}> `); // for more info about why we need the ignoreRules, see: https://lion.js.org/fundamentals/systems/overlays/rationale/#considerations await expect(el).to.be.accessible({ ignoredRules: ['aria-allowed-role'] }); }); it('does not have a tabindex', async () => { const el = await fixture(html`<${tag}></${tag}>`); expect(el.hasAttribute('tabindex')).to.be.false; }); it('creates unique ids for all children', async () => { const el = await fixture(html` <${tag}> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${20} selected>Item 2</${optionTag}> <${optionTag} .choiceValue=${30} id="predefined">Item 3</${optionTag}> </${tag}> `); expect(el.querySelectorAll(cfg.optionTagString)[0].id).to.exist; expect(el.querySelectorAll(cfg.optionTagString)[1].id).to.exist; expect(el.querySelectorAll(cfg.optionTagString)[2].id).to.equal('predefined'); }); it('has a reference to the active option', async () => { const el = await fixture(html` <${tag} opened has-no-default-selected autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue=${'10'} id="first">Item 1</${optionTag}> <${optionTag} .choiceValue=${'20'} id="second">Item 2</${optionTag}> </${tag}> `); const { _activeDescendantOwnerNode } = getListboxMembers(el); expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.be.null; await el.updateComplete; // Normalize el.activeIndex = 0; await el.updateComplete; expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('first'); mimicKeyPress(_activeDescendantOwnerNode, 'ArrowDown'); await el.updateComplete; expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('second'); }); it('puts "aria-setsize" on all options to indicate the total amount of options', async () => { const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> <${optionTag} .choiceValue=${30}>Item 3</${optionTag}> </${tag}> `) ); el.formElements.forEach(optionEl => { expect(optionEl.getAttribute('aria-setsize')).to.equal('3'); }); }); it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => { const el = await fixture(html` <${tag} autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> <${optionTag} .choiceValue=${30}>Item 3</${optionTag}> </${tag}> `); el.formElements.forEach((oEl, i) => { expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`); }); }); }); describe('Values', () => { // TODO: ChoiceGroup suite? it('registers options', async () => { const el = await fixture(html` <${tag}> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> </${tag}> `); expect(el.formElements.length).to.equal(2); expect(el.formElements).to.eql([ el.querySelectorAll('lion-option')[0], el.querySelectorAll('lion-option')[1], ]); }); it('allows null choiceValue', async () => { const el = await fixture(html` <${tag}> <${optionTag} checked .choiceValue=${null}>Please select value</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> </${tag}> `); // @ts-ignore feature detect LionCombobox if (el._comboboxNode) { // note that the modelValue can only be supplied as string if we have a textbox // (parsers not supported atm) expect(el.modelValue).to.equal(''); return; } expect(el.modelValue).to.be.null; }); it('should reset selected value to initial value', async () => { const el = await fixture(html` <${tag}> <${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}> <${optionTag} .choiceValue=${'20'} checked>Item 2</${optionTag}> </${tag}> `); expect(el.modelValue).to.equal('20'); el.setCheckedIndex(0); expect(el.modelValue).to.equal('10'); el.reset(); expect(el.modelValue).to.equal('20'); }); it('should reset selected value to "Please select account" with has-no-default-selected attribute', async () => { const el = await fixture(html` <${tag} has-no-default-selected> <${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}> <${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}> </${tag}> `); el.setCheckedIndex(1); expect(el.modelValue).to.equal('20'); el.reset(); expect(el.modelValue).to.equal(''); }); it('should reset selected value to initial values when multiple-choice', async () => { const el = await fixture(html` <${tag} multiple-choice has-no-default-selected> <${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}> <${optionTag} .choiceValue=${'20'} checked>Item 2</${optionTag}> <${optionTag} .choiceValue=${'30'} checked>Item 3</${optionTag}> </${tag}> `); el.setCheckedIndex(0); expect(el.modelValue).to.deep.equal(['10', '20', '30']); el.reset(); expect(el.modelValue).to.deep.equal(['20', '30']); }); it('has an activeIndex', async () => { const el = await fixture(html` <${tag} has-no-default-selected autocomplete="list"> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> </${tag}> `); const options = el.formElements; expect(el.activeIndex).to.equal(-1); options[1].active = true; expect(options[0].active).to.be.false; expect(el.activeIndex).to.equal(1); }); it('should reset activeIndex value', async () => { const el = await fixture(html` <${tag} has-no-default-selected> <${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}> <${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}> </${tag}> `); const options = el.formElements; expect(el.activeIndex).to.equal(-1); options[1].active = true; expect(el.activeIndex).to.equal(1); el.reset(); expect(el.activeIndex).to.equal(-1); }); }); describe('Interactions', () => { describe('Keyboard navigation', () => { describe('Rotate Keyboard Navigation', () => { it('stops navigation by default at end of option list', async () => { const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} opened name="foo" .rotateKeyboardNavigation="${false}"> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `) ); const { _listboxNode } = getListboxMembers(el); // Normalize el.activeIndex = 0; const options = el.formElements; mimicKeyPress(_listboxNode, 'ArrowUp'); expect(options[0].active).to.be.true; expect(options[1].active).to.be.false; expect(options[2].active).to.be.false; el.activeIndex = 2; mimicKeyPress(_listboxNode, 'ArrowDown'); expect(options[0].active).to.be.false; expect(options[1].active).to.be.false; expect(options[2].active).to.be.true; }); it('when "rotate-navigation" provided, selects first option after navigated to next from last and vice versa', async () => { const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} opened name="foo" rotate-keyboard-navigation autocomplete="inline"> <${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `) ); const { _inputNode } = getListboxMembers(el); _inputNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); await el.updateComplete; // Normalize el.activeIndex = 0; expect(el.activeIndex).to.equal(0); // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); mimicKeyPress(_inputNode, 'ArrowUp'); await el.updateComplete; expect(el.activeIndex).to.equal(2); // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); mimicKeyPress(_inputNode, 'ArrowDown'); expect(el.activeIndex).to.equal(0); // Extra check: regular navigation // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); mimicKeyPress(_inputNode, 'ArrowDown'); expect(el.activeIndex).to.equal(1); }); }); describe('Enter', () => { it('[Enter] selects active option', async () => { const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} opened name="foo" autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `) ); const { _listboxNode } = getListboxMembers(el); // Normalize suite el.activeIndex = 0; const options = el.formElements; el.checkedIndex = 0; mimicKeyPress(_listboxNode, 'ArrowDown'); mimicKeyPress(_listboxNode, 'Enter'); expect(options[1].checked).to.be.true; }); it('submits form on [Enter] when inputNode is an instance of HTMLInputNode', async () => { const submitSpy = sinon.spy(e => e.preventDefault()); const el = /** @type {HTMLFormElement} */ ( await _fixture(html` <form @submit=${submitSpy}> <${tag} name="foo"> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> <button type="submit">submit</button> </form> `) ); const listbox = /** @type {LionListbox} */ (el.querySelector('[name="foo"]')); const { _inputNode } = getListboxMembers(listbox); if (!(_inputNode instanceof HTMLInputElement)) { return; } await listbox.updateComplete; // @ts-ignore allow protected members in tests _inputNode.focus(); await sendKeys({ press: 'Enter', }); expect(submitSpy.callCount).to.equal(1); }); }); describe('Space', () => { it('selects active option when "_listboxReceivesNoFocus" is true', async () => { // When listbox is not focusable (in case of a combobox), the user should be allowed // to enter a space in the focusable element (textbox) const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} opened name="foo" ._listboxReceivesNoFocus="${false}" autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `) ); const { _listboxNode } = getListboxMembers(el); // Normalize suite el.activeIndex = 0; const options = el.formElements; el.checkedIndex = 0; mimicKeyPress(_listboxNode, 'ArrowDown'); mimicKeyPress(_listboxNode, ' '); expect(options[1].checked).to.be.true; el.checkedIndex = 0; // @ts-ignore allow protected member access in test el._listboxReceivesNoFocus = true; mimicKeyPress(_listboxNode, 'ArrowDown'); mimicKeyPress(_listboxNode, ' '); expect(options[1].checked).to.be.false; }); }); // TODO: add key combinations like shift+home/ctrl+A etc etc. describe('Typeahead', () => { it('activates a value with single [character] key', async () => { const el = await fixture(html` <${tag} opened id="color" name="color" label="Favorite color"> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}> </${tag}> `); // @ts-expect-error [allow-protected-in-tests] if (el._noTypeAhead) { return; } const { _listboxNode } = getListboxMembers(el); // Normalize start values between listbox, select and combobox and test interaction below el.activeIndex = 0; mimicKeyPress(_listboxNode, 't', 'KeyT'); // await aTimeout(0); expect(el.activeIndex).to.equal(1); }); it('activates a value with multiple [character] keys', async () => { const el = await fixture(html` <${tag} opened id="color" name="color" label="Favorite color"> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}> </${tag}> `); // @ts-expect-error [allow-protected-in-tests] if (el._noTypeAhead) { return; } const { _listboxNode } = getListboxMembers(el); // Normalize start values between listbox, select and combobox and test interaction below el.activeIndex = 0; mimicKeyPress(_listboxNode, 't', 'KeyT'); expect(el.activeIndex).to.equal(1); mimicKeyPress(_listboxNode, 'u', 'KeyU'); expect(el.activeIndex).to.equal(2); }); it('selects a value with [character] keys and selectionFollowsFocus', async () => { const el = await fixture(html` <${tag} opened id="color" name="color" label="Favorite color" selection-follows-focus> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}> </${tag}> `); // @ts-expect-error [allow-protected-in-tests] if (el._noTypeAhead) { return; } const { _listboxNode } = getListboxMembers(el); // Normalize start values between listbox, select and combobox and test interaction below el.checkedIndex = 0; mimicKeyPress(_listboxNode, 't', 'KeyT'); expect(el.checkedIndex).to.equal(1); mimicKeyPress(_listboxNode, 'u', 'KeyU'); expect(el.checkedIndex).to.equal(2); }); it('clears typedChars after _typeAheadTimeout', async () => { const el = await fixture(html` <${tag} opened id="color" name="color" label="Favorite color"> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>turquoise</${optionTag}> </${tag}> `); // @ts-expect-error [allow-protected-in-tests] if (el._noTypeAhead) { return; } const clock = sinon.useFakeTimers(); const { _listboxNode } = getListboxMembers(el); mimicKeyPress(_listboxNode, 't', 'KeyT'); // @ts-ignore [allow-private] in test expect(el.__typedChars).to.deep.equal(['t']); mimicKeyPress(_listboxNode, 'u', 'KeyU'); // @ts-ignore [allow-private] in test expect(el.__typedChars).to.deep.equal(['t', 'u']); clock.tick(1000); // @ts-ignore [allow-private] in test expect(el.__typedChars).to.deep.equal([]); clock.restore(); }); it('clears scheduled timeouts', async () => { const el = await fixture(html` <${tag} opened id="color" name="color" label="Favorite color"> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}> </${tag}> `); // @ts-expect-error [allow-protected-in-tests] if (el._noTypeAhead) { return; } const { _listboxNode } = getListboxMembers(el); // Normalize start values between listbox, select and combobox and test interaction below el.activeIndex = 0; mimicKeyPress(_listboxNode, 't', 'KeyT'); // @ts-expect-error [allow-private-in-tests] const pendingClear = el.__pendingTypeAheadTimeout; const clearTimeoutSpy = sinon.spy(window, 'clearTimeout'); mimicKeyPress(_listboxNode, 'u', 'KeyU'); expect(clearTimeoutSpy.args[0][0]).to.equal(pendingClear); }); }); it('navigates to first and last option with [Home] and [End] keys', async () => { const el = await fixture(html` <${tag} opened> <${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}> <${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}> <${optionTag} .choiceValue=${'30'} checked>Item 3</${optionTag}> <${optionTag} .choiceValue=${'40'}>Item 4</${optionTag}> </${tag}> `); const { _listboxNode } = getListboxMembers(el); // @ts-ignore allow protected members in tests if (el._listboxReceivesNoFocus) { return; } el.activeIndex = 2; mimicKeyPress(_listboxNode, 'Home'); expect(el.activeIndex).to.equal(0); mimicKeyPress(_listboxNode, 'End'); expect(el.activeIndex).to.equal(3); }); it('navigates through open lists with [ArrowDown] [ArrowUp] keys activates the option', async () => { const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} opened has-no-default-selected autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue=${'Item 1'}>Item 1</${optionTag}> <${optionTag} .choiceValue=${'Item 2'}>Item 2</${optionTag}> <${optionTag} .choiceValue=${'Item 3'}>Item 3</${optionTag}> </${tag}> `) ); const { _listboxNode } = getListboxMembers(el); // Normalize across listbox/select-rich/combobox el.activeIndex = 0; // selectionFollowsFocus will be true by default on combobox (running this suite), // but should still be able to work with selectionFollowsFocus=false el.selectionFollowsFocus = false; expect(el.activeIndex).to.equal(0); expect(el.checkedIndex).to.equal(-1); mimicKeyPress(_listboxNode, 'ArrowDown'); expect(el.activeIndex).to.equal(1); expect(el.checkedIndex).to.equal(-1); mimicKeyPress(_listboxNode, 'ArrowUp'); expect(el.activeIndex).to.equal(0); expect(el.checkedIndex).to.equal(-1); }); }); describe('Orientation', () => { it('has a default value of "vertical"', async () => { const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} opened name="foo" autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `) ); const { _listboxNode } = getListboxMembers(el); expect(el.orientation).to.equal('vertical'); const options = el.formElements; // Normalize for suite tests el.activeIndex = 0; await el.updateComplete; expect(options[0].active).to.be.true; expect(options[1].active).to.be.false; mimicKeyPress(_listboxNode, 'ArrowDown'); expect(options[0].active).to.be.false; expect(options[1].active).to.be.true; mimicKeyPress(_listboxNode, 'ArrowUp'); expect(options[0].active).to.be.true; expect(options[1].active).to.be.false; // No response to horizontal arrows... mimicKeyPress(_listboxNode, 'ArrowRight'); expect(options[0].active).to.be.true; expect(options[1].active).to.be.false; el.activeIndex = 1; mimicKeyPress(_listboxNode, 'ArrowLeft'); expect(options[0].active).to.be.false; expect(options[1].active).to.be.true; }); it('uses [ArrowLeft] and [ArrowRight] keys when "horizontal"', async () => { const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} ._listboxReceivesNoFocus="${false}" opened name="foo" orientation="horizontal" autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `) ); const { _listboxNode } = getListboxMembers(el); expect(el.orientation).to.equal('horizontal'); // Normalize for suite tests el.activeIndex = 0; await el.updateComplete; mimicKeyPress(_listboxNode, 'ArrowRight'); expect(el.activeIndex).to.equal(1); mimicKeyPress(_listboxNode, 'ArrowLeft'); expect(el.activeIndex).to.equal(0); // No response to vertical arrows... mimicKeyPress(_listboxNode, 'ArrowDown'); expect(el.activeIndex).to.equal(0); el.activeIndex = 1; mimicKeyPress(_listboxNode, 'ArrowUp'); expect(el.activeIndex).to.equal(1); // @ts-ignore allow protected member access in test el._listboxReceivesNoFocus = true; mimicKeyPress(_listboxNode, 'ArrowLeft'); expect(el.activeIndex).to.equal(1); mimicKeyPress(_listboxNode, 'ArrowRight'); expect(el.activeIndex).to.equal(1); }); describe('Accessibility', () => { it('adds aria-orientation attribute to listbox node', async () => { const el = await fixture(html` <${tag} name="foo" orientation="horizontal"> <${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `); const { _listboxNode } = getListboxMembers(el); expect(_listboxNode.getAttribute('aria-orientation')).to.equal('horizontal'); }); }); }); describe('Multiple Choice', () => { it('does not uncheck siblings', async () => { const el = await fixture(html` <${tag} name="foo" multiple-choice> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> <${optionTag} .choiceValue="${'Chicory'}">Chicory</${optionTag}> <${optionTag} .choiceValue="${'Victoria Plum'}">Victoria Plum</${optionTag}> </${tag}> `); const options = el.formElements; options[0].checked = true; options[1].checked = true; expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke', 'Chard']); }); it('works via different interaction mechanisms (click, enter, spaces)', async () => { const el = await fixture(html` <${tag} name="foo" multiple-choice> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> <${optionTag} .choiceValue="${'Chicory'}">Chicory</${optionTag}> <${optionTag} .choiceValue="${'Victoria Plum'}">Victoria Plum</${optionTag}> </${tag}> `); const { _listboxNode } = getListboxMembers(el); const options = el.formElements; // @ts-ignore feature detection select-rich if (el.navigateWithinInvoker !== undefined) { // Note we don't have multipleChoice in the select-rich yet. // TODO: implement in future when requested return; } // click options[0].click(); options[1].click(); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke', 'Chard']); // also deselect options[1].click(); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke']); // Reset // @ts-ignore allow protected members in tests el._uncheckChildren(); // Enter el.activeIndex = 0; mimicKeyPress(_listboxNode, 'Enter'); el.activeIndex = 1; mimicKeyPress(_listboxNode, 'Enter'); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke', 'Chard']); // also deselect mimicKeyPress(_listboxNode, 'Enter'); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke']); // @ts-ignore allow protected if (el._listboxReceivesNoFocus) { return; // if suite is run for combobox, we don't respond to [Space] } // Reset // @ts-ignore allow protected members in tests el._uncheckChildren(); // Space el.activeIndex = 0; mimicKeyPress(_listboxNode, ' '); el.activeIndex = 1; mimicKeyPress(_listboxNode, ' '); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke', 'Chard']); // also deselect mimicKeyPress(_listboxNode, ' '); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke']); }); describe('Accessibility', () => { it('adds aria-multiselectable="true" to listbox node', async () => { const el = await fixture(html` <${tag} name="foo" multiple-choice> <${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `); const { _listboxNode } = getListboxMembers(el); expect(_listboxNode.getAttribute('aria-multiselectable')).to.equal('true'); }); it('does not allow "selectionFollowsFocus"', async () => { const el = await fixture(html` <${tag} name="foo" multiple-choice> <${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke</${optionTag}> <${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}> </${tag}> `); const { _listboxNode, _inputNode } = getListboxMembers(el); _inputNode.focus(); _listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); expect(_listboxNode.getAttribute('aria-multiselectable')).to.equal('true'); }); }); }); describe('Selection Follows Focus', () => { it('navigates through list with [ArrowDown] [ArrowUp] keys: activates and checks the option', async () => { /** * @param {LionOption[]} options * @param {number} selectedIndex */ function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) { options.forEach((option, i) => { if (i === selectedIndex) { expect(option.checked).to.be.true; } else { expect(option.checked).to.be.false; } }); } const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} opened selection-follows-focus autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}> <${optionTag} .choiceValue=${30}>Item 3</${optionTag}> </${tag}> `) ); const { _listboxNode } = getListboxMembers(el); const options = el.formElements; // Normalize start values between listbox, select and combobox and test interaction below el.activeIndex = 0; el.checkedIndex = 0; expect(el.activeIndex).to.equal(0); expect(el.checkedIndex).to.equal(0); expectOnlyGivenOneOptionToBeChecked(options, 0); mimicKeyPress(_listboxNode, 'ArrowDown'); expect(el.activeIndex).to.equal(1); expect(el.checkedIndex).to.equal(1); expectOnlyGivenOneOptionToBeChecked(options, 1); mimicKeyPress(_listboxNode, 'ArrowUp'); expect(el.activeIndex).to.equal(0); expect(el.checkedIndex).to.equal(0); expectOnlyGivenOneOptionToBeChecked(options, 0); }); it('navigates through list with [ArrowLeft] [ArrowRight] keys when horizontal: activates and checks the option', async () => { /** * @param {LionOption[]} options * @param {number} selectedIndex */ function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) { options.forEach((option, i) => { if (i === selectedIndex) { expect(option.checked).to.be.true; } else { expect(option.checked).to.be.false; } }); } const el = /** @type {LionListbox} */ ( await fixture(html` <${tag} ._listboxReceivesNoFocus="${false}" opened selection-follows-focus orientation=