import { LitElement } from '@lion/core'; import { formFixture as fixture } from '@lion/field/test-helpers.js'; import { OverlayController } from '@lion/overlays'; import { Required } from '@lion/validate'; import { aTimeout, defineCE, expect, html, nextFrame, unsafeStatic } from '@open-wc/testing'; import { LionSelectInvoker, LionSelectRich } from '../index.js'; import '../lion-option.js'; import '../lion-options.js'; import '../lion-select-rich.js'; import './keyboardEventShimIE.js'; describe('lion-select-rich', () => { it('has a single modelValue representing the currently checked option', async () => { const el = await fixture(html` Item 1 Item 2 `); expect(el.modelValue).to.equal(10); }); it('automatically sets the name attribute of child checkboxes to its own name', async () => { const el = await fixture(html` Item 1 Item 2 `); await nextFrame(); expect(el.formElements[0].name).to.equal('foo'); expect(el.formElements[1].name).to.equal('foo'); const validChild = await fixture(html` Item 3 `); 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` Item 1 Item 2 `); await nextFrame(); const invalidChild = await fixture(html` `); expect(() => { el.addFormElement(invalidChild); }).to.throw( 'The lion-select-rich name="foo" does not allow to register lion-option with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }', ); }); it('throws if a child element with a different name than the group tries to register', async () => { const el = await fixture(html` `); await nextFrame(); const invalidChild = await fixture(html` `); expect(() => { el.addFormElement(invalidChild); }).to.throw( 'The lion-select-rich name="gender" does not allow to register lion-option with custom names (name="foo" given)', ); }); it('can set initial modelValue on creation', async () => { const el = await fixture(html` `); expect(el.modelValue).to.equal('other'); expect(el.formElements[2].checked).to.be.true; }); it(`has a fieldName based on the label`, async () => { const el1 = await fixture( html` `, ); expect(el1.fieldName).to.equal(el1._labelNode.textContent); const el2 = await fixture( html` `, ); expect(el2.fieldName).to.equal(el2._labelNode.textContent); }); it(`has a fieldName based on the name if no label exists`, async () => { const el = await fixture( html` `, ); expect(el.fieldName).to.equal(el.name); }); it(`can override fieldName`, async () => { const el = await fixture( html` `, ); expect(el.__fieldName).to.equal(el.fieldName); }); it('does not have a tabindex', async () => { const el = await fixture(html` `); expect(el.hasAttribute('tabindex')).to.be.false; }); it('delegates the name attribute to its children options', async () => { const el = await fixture(html` Item 1 Item 2 `); const optOne = el.querySelectorAll('lion-option')[0]; const optTwo = el.querySelectorAll('lion-option')[1]; expect(optOne.name).to.equal('foo'); expect(optTwo.name).to.equal('foo'); }); it('supports validation', async () => { const el = await fixture(html` select a color Red Hotpink Teal `); 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._listboxNode.children[1].checked = true; await el.updateComplete; expect(el.hasFeedbackFor.includes('error')).to.be.false; expect(el.showsFeedbackFor.includes('error')).to.be.false; el._listboxNode.children[0].checked = true; await el.updateComplete; expect(el.hasFeedbackFor.includes('error')).to.be.true; expect(el.showsFeedbackFor.includes('error')).to.be.true; }); it('supports having no default selection initially', async () => { const el = await fixture(html` Red Hotpink Teal `); expect(el.selectedElement).to.be.undefined; expect(el.modelValue).to.equal(''); }); describe('Invoker', () => { it('generates an lion-select-invoker if no invoker is provided', async () => { const el = await fixture(html` `); expect(el._invokerNode).to.exist; expect(el._invokerNode.tagName).to.include('LION-SELECT-INVOKER'); }); it('sets the first option as the selectedElement if no option is checked', async () => { const el = await fixture(html` Item 1 Item 2 `); const options = Array.from(el.querySelectorAll('lion-option')); expect(el._invokerNode.selectedElement).dom.to.equal(options[0]); }); it('syncs the selected element to the invoker', async () => { const el = await fixture(html` Item 1 Item 2 `); const options = el.querySelectorAll('lion-option'); expect(el._invokerNode.selectedElement).dom.to.equal(options[1]); el.checkedIndex = 0; await el.updateComplete; expect(el._invokerNode.selectedElement).dom.to.equal(options[0]); }); it('delegates readonly to the invoker', async () => { const el = await fixture(html` Item 1 Item 2 `); expect(el.hasAttribute('readonly')).to.be.true; expect(el._invokerNode.hasAttribute('readonly')).to.be.true; }); it('delegates singleOption to the invoker', async () => { const el = await fixture(html` Item 1 `); expect(el.singleOption).to.be.true; expect(el._invokerNode.hasAttribute('single-option')).to.be.true; }); }); describe('overlay', () => { it('should be closed by default', async () => { const el = await fixture(html` `); expect(el.opened).to.be.false; }); it('shows/hides the listbox via opened attribute', async () => { const el = await fixture(html` `); el.opened = true; await el.updateComplete; expect(el._overlayCtrl.isShown).to.be.true; el.opened = false; await el.updateComplete; expect(el._overlayCtrl.isShown).to.be.false; }); it('syncs opened state with overlay shown', async () => { const el = await fixture(html` `); const outerEl = await fixture(''); expect(el.opened).to.be.true; // a click on the button will trigger hide on outside click // which we then need to sync back to "opened" outerEl.click(); await aTimeout(); expect(el.opened).to.be.false; }); it('will focus the listbox on open and invoker on close', async () => { const el = await fixture(html` `); await el._overlayCtrl.show(); await el.updateComplete; expect(document.activeElement === el._listboxNode).to.be.true; expect(document.activeElement === el._invokerNode).to.be.false; el.opened = false; await el.updateComplete; expect(document.activeElement === el._listboxNode).to.be.false; expect(document.activeElement === el._invokerNode).to.be.true; }); it('opens the listbox with checked option as active', async () => { const el = await fixture(html` Item 1 Item 2 `); await el._overlayCtrl.show(); await el.updateComplete; const options = Array.from(el.querySelectorAll('lion-option')); expect(options[1].active).to.be.true; expect(options[1].checked).to.be.true; }); it('stays closed on click if it is disabled or readonly or has a single option', async () => { const elReadOnly = await fixture(html` Item 1 Item 2 `); const elDisabled = await fixture(html` Item 1 Item 2 `); const elSingleoption = await fixture(html` Item 1 `); elReadOnly._invokerNode.click(); await elReadOnly.updateComplete; expect(elReadOnly.opened).to.be.false; elDisabled._invokerNode.click(); await elDisabled.updateComplete; expect(elDisabled.opened).to.be.false; elSingleoption._invokerNode.click(); await elSingleoption.updateComplete; expect(elSingleoption.opened).to.be.false; }); it('should override the inheritsWidth prop when no default selected feature is used', async () => { const el = await fixture(html` Red Hotpink Teal `); expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full'); el.opened = true; await el.updateComplete; expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('min'); // Emulate selecting hotpink, it closing, and opening it again el.modelValue = 'hotpink'; el.opened = false; await el.updateComplete; // necessary for overlay controller to actually close and re-open el.opened = true; await el.updateComplete; expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full'); }); it('should set singleOption to true when options change dynamically to 1 option', async () => { const elSingleoption = await fixture(html` Item 1 Item 2 `); elSingleoption._invokerNode.click(); await elSingleoption.updateComplete; expect(elSingleoption.singleOption).to.be.undefined; const optionELm = elSingleoption.querySelectorAll('lion-option')[0]; optionELm.parentNode.removeChild(optionELm); elSingleoption.requestUpdate(); elSingleoption._invokerNode.click(); await elSingleoption.updateComplete; expect(elSingleoption.singleOption).to.be.true; }); }); describe('interaction-mode', () => { it('allows to specify an interaction-mode which determines other behaviors', async () => { const el = await fixture(html` `); expect(el.interactionMode).to.equal('mac'); }); }); describe('Keyboard navigation', () => { it('opens the listbox with [Enter] key via click handler', async () => { const el = await fixture(html` `); el._invokerNode.click(); await aTimeout(); expect(el.opened).to.be.true; }); it('opens the listbox with [ ](Space) key via click handler', async () => { const el = await fixture(html` `); el._invokerNode.click(); await aTimeout(); expect(el.opened).to.be.true; }); it('closes the listbox with [Escape] key once opened', async () => { const el = await fixture(html` `); el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); await el.updateComplete; expect(el.opened).to.be.false; }); it('closes the listbox with [Tab] key once opened', async () => { const el = await fixture(html` `); // tab can only be caught via keydown el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); await el.updateComplete; expect(el.opened).to.be.false; }); }); describe('Mouse navigation', () => { it('opens the listbox via click on invoker', async () => { const el = await fixture(html` `); expect(el.opened).to.be.false; el._invokerNode.click(); await nextFrame(); expect(el.opened).to.be.true; }); it('closes the listbox when an option gets clicked', async () => { const el = await fixture(html` Item 1 `); expect(el.opened).to.be.true; el.querySelector('lion-option').click(); expect(el.opened).to.be.false; }); }); describe('Keyboard navigation Windows', () => { it('closes the listbox with [Enter] key once opened', async () => { const el = await fixture(html` `); el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); await el.updateComplete; expect(el.opened).to.be.false; }); }); describe('Keyboard navigation Mac', () => { it('checks active item and closes the listbox with [Enter] key via click handler once opened', async () => { const el = await fixture(html` Item 1 Item 2 `); // changes active but not checked el.activeIndex = 1; expect(el.checkedIndex).to.equal(0); el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); expect(el.opened).to.be.false; expect(el.checkedIndex).to.equal(1); }); it('opens the listbox with [ArrowUp] key', async () => { const el = await fixture(html` `); el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); await el.updateComplete; expect(el.opened).to.be.true; }); it('opens the listbox with [ArrowDown] key', async () => { const el = await fixture(html` `); el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); await el.updateComplete; expect(el.opened).to.be.true; }); }); describe('Accessibility', () => { it('has the right references to its inner elements', async () => { const el = await fixture(html` Item 1 Item 2 `); expect(el._invokerNode.getAttribute('aria-labelledby')).to.contain(el._labelNode.id); expect(el._invokerNode.getAttribute('aria-labelledby')).to.contain(el._invokerNode.id); expect(el._invokerNode.getAttribute('aria-describedby')).to.contain(el._helpTextNode.id); expect(el._invokerNode.getAttribute('aria-describedby')).to.contain(el._feedbackNode.id); expect(el._invokerNode.getAttribute('aria-haspopup')).to.equal('listbox'); }); it('notifies when the listbox is expanded or not', async () => { // smoke test for overlay functionality const el = await fixture(html` `); expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false'); el.opened = true; await el.updateComplete; await el.updateComplete; // need 2 awaits as overlay.show is an async function expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('true'); }); it('is accessible when closed', async () => { const el = await fixture(html` Item 1 Item 2 `); await expect(el).to.be.accessible(); }); it('is accessible when opened', async () => { const el = await fixture(html` Item 1 Item 2 `); el.opened = true; await el.updateComplete; await el.updateComplete; // need 2 awaits as overlay.show is an async function await expect(el).to.be.accessible(); }); }); describe('Use cases', () => { it('works for complex array data', async () => { const objs = [ { type: 'mastercard', label: 'Master Card', amount: 12000, active: true }, { type: 'visacard', label: 'Visa Card', amount: 0, active: false }, ]; const el = await fixture(html` ${objs.map( obj => html` ${obj.label} `, )} `); expect(el.modelValue).to.deep.equal({ type: 'mastercard', label: 'Master Card', amount: 12000, active: true, }); el.checkedIndex = 1; expect(el.modelValue).to.deep.equal({ type: 'visacard', label: 'Visa Card', amount: 0, active: false, }); }); it('keeps showing the selected item after a new item has been added in the selectedIndex position', async () => { const mySelectContainerTagString = defineCE( class extends LitElement { static get properties() { return { colorList: Array, }; } constructor() { super(); this.colorList = [ { label: 'Red', value: 'red', checked: false, }, { label: 'Hotpink', value: 'hotpink', checked: true, }, { label: 'Teal', value: 'teal', checked: false, }, ]; } render() { return html` ${this.colorList.map( colorObj => html` ${colorObj.label} `, )} `; } }, ); const mySelectContainerTag = unsafeStatic(mySelectContainerTagString); const el = await fixture(html` <${mySelectContainerTag}> `); const selectRich = el.shadowRoot.querySelector('lion-select-rich'); const invoker = selectRich._invokerNode; expect(selectRich.checkedIndex).to.equal(1); expect(selectRich.modelValue).to.equal('hotpink'); expect(invoker.selectedElement.value).to.equal('hotpink'); const newOption = document.createElement('lion-option'); newOption.modelValue = { checked: false, value: 'blue' }; newOption.textContent = 'Blue'; const hotpinkEl = selectRich._listboxNode.children[1]; hotpinkEl.insertAdjacentElement('beforebegin', newOption); expect(selectRich.checkedIndex).to.equal(2); expect(selectRich.modelValue).to.equal('hotpink'); expect(invoker.selectedElement.value).to.equal('hotpink'); }); }); describe('Subclassers', () => { it('allows to override the type of overlay', async () => { const mySelectTagString = defineCE( class MySelect extends LionSelectRich { _defineOverlay({ invokerNode, contentNode, contentWrapperNode }) { const ctrl = new OverlayController({ placementMode: 'global', contentNode, contentWrapperNode, invokerNode, }); this.addEventListener('switch', () => { ctrl.updateConfig({ placementMode: 'local' }); }); return ctrl; } }, ); const mySelectTag = unsafeStatic(mySelectTagString); const el = await fixture(html` <${mySelectTag} label="Favorite color" name="color"> ${Array(2).map( (_, i) => html` value ${i} `, )} `); await el.updateComplete; expect(el._overlayCtrl.placementMode).to.equal('global'); el.dispatchEvent(new Event('switch')); expect(el._overlayCtrl.placementMode).to.equal('local'); }); it('supports putting a placeholder template when there is no default selection initially', async () => { const invokerTagName = defineCE( class extends LionSelectInvoker { _noSelectionTemplate() { return html` Please select an option.. `; } }, ); const invokerTag = unsafeStatic(invokerTagName); const selectTagName = defineCE( class extends LionSelectRich { get slots() { return { ...super.slots, invoker: () => document.createElement(invokerTag.d), }; } }, ); const selectTag = unsafeStatic(selectTagName); const el = await fixture(html` <${selectTag} id="color" name="color" label="Favorite color" has-no-default-selected> Red Hotpink Teal `); expect(el._invokerNode.shadowRoot.getElementById('content-wrapper')).dom.to.equal( `
Please select an option..
`, ); expect(el.modelValue).to.equal(''); }); }); });