import { LitElement } from '@lion/core';
import { OverlayController } from '@lion/overlays';
import { Required } from '@lion/validate';
import {
aTimeout,
defineCE,
expect,
fixture,
html,
nextFrame,
unsafeStatic,
} from '@open-wc/testing';
import { 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;
el._listboxNode.children[1].checked = true;
// Set touched to true (needed for feedback show) because we simulate a user touching the select
el.touched = 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;
});
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.equal('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;
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;
});
});
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', async () => {
const elReadOnly = await fixture(html`
Item 1
Item 2
`);
const elDisabled = await fixture(html`
Item 1
Item 2
`);
elReadOnly._invokerNode.click();
await elReadOnly.updateComplete;
expect(elReadOnly.opened).to.be.false;
elDisabled._invokerNode.click();
await elDisabled.updateComplete;
expect(elDisabled.opened).to.be.false;
});
});
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}>${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 }) {
const ctrl = new OverlayController({
placementMode: 'global',
contentNode,
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}
`,
)}
${mySelectTag}>
`);
expect(el._overlayCtrl.placementMode).to.equal('global');
el.dispatchEvent(new Event('switch'));
expect(el._overlayCtrl.placementMode).to.equal('local');
});
});
});