chore: types for listbox and select-rich

This commit is contained in:
Thijs Louisse 2020-10-05 17:42:29 +02:00
parent 928a673a2f
commit 5aadf0b2f9
10 changed files with 618 additions and 336 deletions

View file

@ -1,5 +1,5 @@
import { Required } from '@lion/form-core';
import { expect, html, fixture, unsafeStatic } from '@open-wc/testing';
import { expect, html, fixture as _fixture, unsafeStatic } from '@open-wc/testing';
import { LionOptions } from '@lion/listbox';
import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
@ -7,12 +7,33 @@ import '../lion-listbox.js';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
/**
* @typedef {import('@lion/combobox/src/LionCombobox').LionCombobox} LionCombobox
* @typedef {import('../src/LionListbox').LionListbox} LionListbox
* @typedef {import('../src/LionOption').LionOption} LionOption
* @typedef {import('@lion/select-rich').LionSelectInvoker} LionSelectInvoker
* @typedef {import('lit-html').TemplateResult} TemplateResult
*/
const fixture = /** @type {(arg: TemplateResult) => Promise<LionListbox>} */ (_fixture);
/**
* @param { {tagString:string, optionTagString:string} } [customConfig]
* @param {LionListbox} lionListboxEl
*/
function getProtectedMembers(lionListboxEl) {
// @ts-ignore protected members allowed in test
const {
_inputNode: input,
_activeDescendantOwnerNode: activeDescendantOwner,
_listboxNode: listbox,
} = lionListboxEl;
return {
input,
activeDescendantOwner,
listbox,
};
}
/**
* @param { {tagString?:string, optionTagString?:string} } [customConfig]
*/
export function runListboxMixinSuite(customConfig = {}) {
const cfg = {
@ -157,12 +178,12 @@ export function runListboxMixinSuite(customConfig = {}) {
await el.updateComplete;
expect(el.showsFeedbackFor.includes('error')).to.be.true;
el._listboxNode.children[1].checked = 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._listboxNode.children[0].checked = true;
el.formElements[0].checked = true;
await el.updateComplete;
expect(el.hasFeedbackFor.includes('error')).to.be.true;
expect(el.showsFeedbackFor.includes('error')).to.be.true;
@ -170,19 +191,6 @@ export function runListboxMixinSuite(customConfig = {}) {
});
describe('Selection', () => {
it('supports having no default selection initially', 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.selectedElement).to.be.undefined;
expect(el.modelValue).to.equal('');
});
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>
@ -203,6 +211,20 @@ export function runListboxMixinSuite(customConfig = {}) {
});
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
await expect(el).to.be.accessible();
});
// 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">
@ -213,20 +235,6 @@ export function runListboxMixinSuite(customConfig = {}) {
await expect(el).to.be.accessible();
});
it('[axe]: is accessible when opened', async () => {
const el = await fixture(html`
<${tag} label="age">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}>
`);
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();
});
it('does not have a tabindex', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
expect(el.hasAttribute('tabindex')).to.be.false;
@ -252,7 +260,9 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${'20'} checked id="second">Item 2</${optionTag}>
</${tag}>
`);
expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.be.null;
const { activeDescendantOwner } = getProtectedMembers(el);
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.be.null;
await el.updateComplete;
// Normalize
@ -262,16 +272,10 @@ export function runListboxMixinSuite(customConfig = {}) {
// new KeyboardEvent('keydown', { key: 'ArrowDown' }),
// );
await el.updateComplete;
expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal(
'first',
);
el._activeDescendantOwnerNode.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown' }),
);
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('first');
activeDescendantOwner.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
await el.updateComplete;
expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal(
'second',
);
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('second');
});
it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
@ -288,15 +292,14 @@ export function runListboxMixinSuite(customConfig = {}) {
});
it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => {
const el = /** @type {LionListbox} */ (await fixture(html`
const el = await fixture(html`
<${tag} autocomplete="none">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
</${tag}>
`));
const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
optionEls.forEach((oEl, i) => {
`);
el.formElements.forEach((oEl, i) => {
expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`);
});
});
@ -326,6 +329,7 @@ export function runListboxMixinSuite(customConfig = {}) {
</${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)
@ -383,10 +387,11 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}>
`);
expect(el.activeIndex).to.equal(-1);
const options = el.formElements;
el.querySelectorAll('lion-option')[1].active = true;
expect(el.querySelectorAll('lion-option')[0].active).to.be.false;
expect(el.activeIndex).to.equal(-1);
options[1].active = true;
expect(options[0].active).to.be.false;
expect(el.activeIndex).to.equal(1);
});
@ -397,8 +402,10 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}>
</${tag}>
`);
const options = el.formElements;
expect(el.activeIndex).to.equal(-1);
el.querySelectorAll('lion-option')[1].active = true;
options[1].active = true;
expect(el.activeIndex).to.equal(1);
el.reset();
expect(el.activeIndex).to.equal(-1);
@ -416,15 +423,17 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
</${tag}>
`));
const { listbox } = getProtectedMembers(el);
// Normalize
el.activeIndex = 0;
const options = el.formElements;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: '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;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(options[0].active).to.be.false;
expect(options[1].active).to.be.false;
expect(options[2].active).to.be.true;
@ -466,12 +475,14 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
</${tag}>
`));
const { listbox } = getProtectedMembers(el);
// Normalize suite
el.activeIndex = 0;
const options = el.formElements;
el.checkedIndex = 0;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(options[1].checked).to.be.true;
});
});
@ -487,17 +498,20 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
</${tag}>
`));
const { listbox } = getProtectedMembers(el);
// Normalize suite
el.activeIndex = 0;
const options = el.formElements;
el.checkedIndex = 0;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(options[1].checked).to.be.true;
el.checkedIndex = 0;
// @ts-ignore allow protected member access in test
el._listboxReceivesNoFocus = true;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(options[1].checked).to.be.false;
});
});
@ -511,9 +525,9 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${'c'}>C</${optionTag}>
</${tag}>
`);
expect(el.choiceValue).to.equal('a');
expect(el.modelValue).to.equal('a');
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' }));
expect(el.choiceValue).to.equal('c');
expect(el.modelValue).to.equal('c');
});
it.skip('selects a value with multiple [character] keys', async () => {
const el = await fixture(html`
@ -524,9 +538,9 @@ export function runListboxMixinSuite(customConfig = {}) {
</${tag}>
`);
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F' }));
expect(el.choiceValue).to.equal('far');
expect(el.modelValue).to.equal('far');
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' }));
expect(el.choiceValue).to.equal('foo');
expect(el.modelValue).to.equal('foo');
});
it('navigates to first and last option with [Home] and [End] keys', async () => {
const el = await fixture(html`
@ -537,15 +551,17 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${'40'}>Item 4</${optionTag}>
</${tag}>
`);
const { listbox } = getProtectedMembers(el);
// @ts-ignore allow protected members in tests
if (el._listboxReceivesNoFocus) {
return;
}
el.activeIndex = 2;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
expect(el.activeIndex).to.equal(0);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
expect(el.activeIndex).to.equal(3);
});
it('navigates through open lists with [ArrowDown] [ArrowUp] keys activates the option', async () => {
@ -556,6 +572,8 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${'Item 3'}>Item 3</${optionTag}>
</${tag}>
`));
const { listbox } = getProtectedMembers(el);
// Normalize across listbox/select-rich/combobox
el.activeIndex = 0;
// selectionFollowsFocus will be true by default on combobox (running this suite),
@ -563,10 +581,10 @@ export function runListboxMixinSuite(customConfig = {}) {
el.selectionFollowsFocus = false;
expect(el.activeIndex).to.equal(0);
expect(el.checkedIndex).to.equal(-1);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.activeIndex).to.equal(1);
expect(el.checkedIndex).to.equal(-1);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(el.activeIndex).to.equal(0);
expect(el.checkedIndex).to.equal(-1);
});
@ -580,6 +598,8 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
</${tag}>
`));
const { listbox } = getProtectedMembers(el);
expect(el.orientation).to.equal('vertical');
const options = el.formElements;
// Normalize for suite tests
@ -589,21 +609,21 @@ export function runListboxMixinSuite(customConfig = {}) {
expect(options[0].active).to.be.true;
expect(options[1].active).to.be.false;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(options[0].active).to.be.false;
expect(options[1].active).to.be.true;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(options[0].active).to.be.true;
expect(options[1].active).to.be.false;
// No response to horizontal arrows...
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
expect(options[0].active).to.be.true;
expect(options[1].active).to.be.false;
el.activeIndex = 1;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
expect(options[0].active).to.be.false;
expect(options[1].active).to.be.true;
});
@ -615,6 +635,8 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
</${tag}>
`));
const { listbox } = getProtectedMembers(el);
expect(el.orientation).to.equal('horizontal');
// Normalize for suite tests
@ -622,44 +644,45 @@ export function runListboxMixinSuite(customConfig = {}) {
await el.updateComplete;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
expect(el.activeIndex).to.equal(1);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
expect(el.activeIndex).to.equal(0);
// No response to vertical arrows...
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.activeIndex).to.equal(0);
el.activeIndex = 1;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(el.activeIndex).to.equal(1);
});
describe('Accessibility', () => {
it('adds aria-orientation attribute to listbox node', async () => {
const el = /** @type {Listbox} */ (await fixture(html`
const el = await fixture(html`
<${tag} name="foo" orientation="horizontal">
<${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
</${tag}>
`));
expect(el._listboxNode.getAttribute('aria-orientation')).to.equal('horizontal');
`);
const { listbox } = getProtectedMembers(el);
expect(listbox.getAttribute('aria-orientation')).to.equal('horizontal');
});
});
});
describe('Multiple Choice', () => {
it('does not uncheck siblings', async () => {
const el = /** @type {Listbox} */ (await fixture(html`
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;
@ -668,17 +691,18 @@ export function runListboxMixinSuite(customConfig = {}) {
});
it('works via different interaction mechanisms (click, enter, spaces)', async () => {
const el = /** @type {Listbox} */ (await fixture(html`
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 { listbox } = getProtectedMembers(el);
const options = el.formElements;
// feature detection select-rich
// @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
@ -692,59 +716,69 @@ export function runListboxMixinSuite(customConfig = {}) {
expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
// Reset
// @ts-ignore allow protected members in tests
el._uncheckChildren();
// Enter
el.activeIndex = 0;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
el.activeIndex = 1;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(options[0].checked).to.equal(true);
expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
// @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;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
el.activeIndex = 1;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(options[0].checked).to.equal(true);
expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
});
describe('Accessibility', () => {
it('adds aria-multiselectable="true" to listbox node', async () => {
const el = /** @type {Listbox} */ (await fixture(html`
const el = await fixture(html`
<${tag} name="foo" multiple-choice>
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
</${tag}>
`));
expect(el._listboxNode.getAttribute('aria-multiselectable')).to.equal('true');
`);
const { listbox } = getProtectedMembers(el);
expect(listbox.getAttribute('aria-multiselectable')).to.equal('true');
});
it('does not allow "selectionFollowsFocus"', async () => {
const el = /** @type {Listbox} */ (await fixture(html`
const el = await fixture(html`
<${tag} name="foo" multiple-choice>
<${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
</${tag}>
`));
el._inputNode.focus();
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
expect(el._listboxNode.getAttribute('aria-multiselectable')).to.equal('true');
`);
const { listbox, input } = getProtectedMembers(el);
input.focus();
listbox.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
expect(listbox.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) {
@ -761,18 +795,20 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
</${tag}>
`));
const options = Array.from(el.querySelectorAll('lion-option'));
const { listbox } = getProtectedMembers(el);
const options = el.formElements;
// Normalize start values between listbox, slect 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);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.activeIndex).to.equal(1);
expect(el.checkedIndex).to.equal(1);
expectOnlyGivenOneOptionToBeChecked(options, 1);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(el.activeIndex).to.equal(0);
expect(el.checkedIndex).to.equal(0);
expectOnlyGivenOneOptionToBeChecked(options, 0);
@ -786,15 +822,17 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${'40'}>Item 4</${optionTag}>
</${tag}>
`);
const { listbox } = getProtectedMembers(el);
// @ts-ignore allow protected
if (el._listboxReceivesNoFocus) {
return;
}
expect(el.modelValue).to.equal('30');
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
expect(el.modelValue).to.equal('10');
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
expect(el.modelValue).to.equal('40');
});
});
@ -807,9 +845,11 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} checked .choiceValue=${'20'}>Item 2</${optionTag}>
</${tag}>
`);
const { listbox } = getProtectedMembers(el);
await el.updateComplete;
const { checkedIndex } = el;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.checkedIndex).to.equal(checkedIndex);
});
@ -820,7 +860,7 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}>
`);
const options = [...el.querySelectorAll('lion-option')];
const options = el.formElements;
el.disabled = true;
await el.updateComplete;
expect(options[0].disabled).to.be.true;
@ -839,7 +879,7 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}>
`);
const options = [...el.querySelectorAll('lion-option')];
const options = el.formElements;
expect(options[0].disabled).to.be.true;
expect(options[1].disabled).to.be.true;
@ -860,14 +900,16 @@ export function runListboxMixinSuite(customConfig = {}) {
</${tag}>
`);
const { listbox } = getProtectedMembers(el);
// Normalize activeIndex across multiple implementers of ListboxMixinSuite
el.activeIndex = 0;
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.activeIndex).to.equal(1);
expect(el.checkedIndex).to.equal(0);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
// Checked index stays where it was
expect(el.checkedIndex).to.equal(0);
});
@ -881,11 +923,11 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20} id="myId">Item 2</${optionTag}>
</${tag}>
`);
const opt = el.querySelectorAll('lion-option')[1];
const { activeDescendantOwner } = getProtectedMembers(el);
const opt = el.formElements[1];
opt.active = true;
expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal(
'myId',
);
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('myId');
});
it('can set checked state', async () => {
@ -895,7 +937,7 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}>
`);
const option = el.querySelectorAll('lion-option')[1];
const option = el.formElements[1];
option.checked = true;
expect(el.modelValue).to.equal(20);
});
@ -923,7 +965,7 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}>
`);
const options = el.querySelectorAll('lion-option');
const options = el.formElements;
options[0].checked = true;
expect(options[0].checked).to.be.true;
expect(options[1].checked).to.be.false;
@ -939,7 +981,7 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}>
`);
const options = el.querySelectorAll('lion-option');
const options = el.formElements;
expect(options[0].active).to.be.true;
options[1].active = true;
expect(options[0].active).to.be.false;
@ -990,12 +1032,16 @@ export function runListboxMixinSuite(customConfig = {}) {
`);
expect(el.hasFeedbackFor).to.include('error');
// @ts-expect-error no types for 'have.a.property'
expect(el.validationStates).to.have.a.property('error');
// @ts-expect-error no types for 'have.a.property'
expect(el.validationStates.error).to.have.a.property('Required');
el.modelValue = 20;
expect(el.hasFeedbackFor).not.to.include('error');
// @ts-expect-error no types for 'have.a.property'
expect(el.validationStates).to.have.a.property('error');
// @ts-expect-error no types for 'have.a.property'
expect(el.validationStates.error).not.to.have.a.property('Required');
});
});
@ -1062,14 +1108,15 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}>
`);
const { listbox } = getProtectedMembers(el);
expect(el._listboxNode).to.exist;
expect(el._listboxNode).to.be.instanceOf(LionOptions);
expect(el.querySelector('[role=listbox]')).to.equal(el._listboxNode);
expect(listbox).to.exist;
expect(listbox).to.be.instanceOf(LionOptions);
expect(el.querySelector('[role=listbox]')).to.equal(listbox);
expect(el.formElements.length).to.equal(2);
expect(el._listboxNode.children.length).to.equal(2);
expect(el._listboxNode.children[0].tagName).to.equal(cfg.optionTagString.toUpperCase());
expect(listbox.children.length).to.equal(2);
expect(listbox.children[0].tagName).to.equal(cfg.optionTagString.toUpperCase());
});
});
});

View file

@ -41,10 +41,17 @@ export declare class ListboxHost {
public setCheckedIndex(index: number): void;
/** Reset interaction states and modelValue */
public reset(): void;
protected _scrollTargetNode: LionOptions;
protected _listboxNode: LionOptions;
protected _listboxReceivesNoFocus: boolean;
protected _uncheckChildren(): void;
private __setupListboxNode(): void;
protected _getPreviousEnabledOption(currentIndex: number, offset?: number): number;
@ -64,6 +71,8 @@ export declare class ListboxHost {
protected _setupListboxInteractions(): void;
protected _onChildActiveChanged(ev: Event): void;
protected _activeDescendantOwnerNode: HTMLElement;
}
export declare function ListboxImplementation<T extends Constructor<LitElement>>(

View file

@ -8,6 +8,7 @@ import { css, html } from '@lion/core';
/**
* LionSelectInvoker: invoker button consuming a selected element
*/
// @ts-expect-error
export class LionSelectInvoker extends LionButton {
static get styles() {
return [

View file

@ -6,10 +6,11 @@ import { LionSelectInvoker } from './LionSelectInvoker.js';
/**
* @typedef {import('@lion/listbox').LionOptions} LionOptions
*/
/**
* @typedef {import('@lion/listbox').LionOption} LionOption
* @typedef {import('@open-wc/scoped-elements/src/types').ScopedElementsHost} ScopedElementsHost
* @typedef {import('@lion/form-core/types/registration/FormRegisteringMixinTypes').FormRegisteringHost} FormRegisteringHost
* @typedef {import('@lion/form-core/types/FormControlMixinTypes').FormControlHost} FormControlHost
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
*/
function detectInteractionMode() {
@ -22,6 +23,7 @@ function detectInteractionMode() {
/**
* LionSelectRich: wraps the <lion-listbox> element
*/
// @ts-expect-error
export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) {
static get scopedElements() {
return {
@ -48,6 +50,23 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
};
}
/**
* @enhance FormControlMixin
*/
// eslint-disable-next-line class-methods-use-this
_inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="invoker"></slot>
<div id="overlay-content-node-wrapper">
<slot name="input"></slot>
<slot id="options-outlet"></slot>
</div>
</div>
`;
}
// @ts-ignore
get slots() {
return {
...super.slots,
@ -55,23 +74,25 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
};
}
/** @type {LionSelectInvoker} */
/**
* @protected
* @type {LionSelectInvoker}
*/
get _invokerNode() {
return /** @type {LionSelectInvoker} */ (Array.from(this.children).find(
child => child.slot === 'invoker',
));
}
/**
* @configure ListboxMixin
* @protected
*/
// @ts-ignore
get _scrollTargetNode() {
return this._listboxNode._scrollTargetNode || this._listboxNode;
}
get checkedIndex() {
return /** @type {number} */ (super.checkedIndex);
}
set checkedIndex(i) {
super.checkedIndex = i;
// TODO: should this be defined here or in extension layer?
// @ts-expect-error we allow the _overlayContentNode to define its own _scrollTargetNode
return this._overlayContentNode._scrollTargetNode || this._overlayContentNode;
}
constructor() {
@ -103,7 +124,9 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
connectedCallback() {
super.connectedCallback();
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
this._invokerNode.selectedElement = this.formElements[
/** @type {number} */ (this.checkedIndex)
];
this.__setupInvokerNode();
this.__toggleInvokerDisabled();
this.addEventListener('keyup', this.__onKeyUp);
@ -135,57 +158,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
}
}
/**
* Overrides FormRegistrar adding to make sure children have specific default states when added
*
* @override
* @param {LionOption} child
* @param {Number} indexToInsertAt
*/
addFormElement(child, indexToInsertAt) {
super.addFormElement(child, indexToInsertAt);
// the first elements checked by default
if (
!this.hasNoDefaultSelected &&
!this.__hasInitialSelectedFormElement &&
(!child.disabled || this.disabled)
) {
/* eslint-disable no-param-reassign */
child.active = true;
child.checked = true;
/* eslint-enable no-param-reassign */
this.__hasInitialSelectedFormElement = true;
}
this._onFormElementsChanged();
}
/**
* In the select disabled options are still going to a possible value for example
* when prefilling or programmatically setting it.
*
* @override
*/
_getCheckedElements() {
return this.formElements.filter(el => el.checked);
}
__initInteractionStates() {
this.initInteractionState();
}
/**
* @param {FormRegisteringHost} child the child element (field)
*/
removeFormElement(child) {
super.removeFormElement(child);
this._onFormElementsChanged();
}
_onFormElementsChanged() {
this.singleOption = this.formElements.length === 1;
this._invokerNode.singleOption = this.singleOption;
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
@ -226,25 +198,58 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
}
}
/** @deprecated. use _overlayCtrl.toggle */
/**
* @enhance FprmRegistrarMixin make sure children have specific default states when added
* @param {LionOption & FormControlHost} child
* @param {Number} indexToInsertAt
*/
addFormElement(child, indexToInsertAt) {
super.addFormElement(child, indexToInsertAt);
// the first elements checked by default
if (
!this.hasNoDefaultSelected &&
!this.__hasInitialSelectedFormElement &&
(!child.disabled || this.disabled)
) {
/* eslint-disable no-param-reassign */
child.active = true;
child.checked = true;
/* eslint-enable no-param-reassign */
this.__hasInitialSelectedFormElement = true;
}
this._onFormElementsChanged();
}
/**
* @enhance FprmRegistrarMixin
* @param {FormRegisteringHost} child the child element (field)
*/
removeFormElement(child) {
super.removeFormElement(child);
this._onFormElementsChanged();
}
// TODO: move to overlayMixin and offer open and close
toggle() {
this.opened = !this.opened;
}
/**
* @override
* In the select disabled options are still going to a possible value for example
* when prefilling or programmatically setting it.
* @override ChoiceGroupMixin
*/
// eslint-disable-next-line class-methods-use-this
_inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="invoker"></slot>
<div id="overlay-content-node-wrapper">
<slot name="input"></slot>
<slot id="options-outlet"></slot>
</div>
</div>
`;
_getCheckedElements() {
return this.formElements.filter(el => el.checked);
}
_onFormElementsChanged() {
this.singleOption = this.formElements.length === 1;
this._invokerNode.singleOption = this.singleOption;
}
__initInteractionStates() {
this.initInteractionState();
}
__toggleInvokerDisabled() {
@ -257,7 +262,9 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
__syncInvokerElement() {
// sync to invoker
if (this._invokerNode) {
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
this._invokerNode.selectedElement = this.formElements[
/** @type {number} */ (this.checkedIndex)
];
}
}
@ -290,7 +297,7 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
}
/**
* @override OverlayMixin
* @configure OverlayMixin
*/
// eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() {
@ -323,7 +330,7 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
__overlayOnShow() {
if (this.checkedIndex != null) {
this.activeIndex = this.checkedIndex;
this.activeIndex = /** @type {number} */ (this.checkedIndex);
}
this._listboxNode.focus();
}
@ -332,6 +339,9 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this._invokerNode.focus();
}
/**
* @enhance OverlayMixin
*/
_setupOverlayCtrl() {
super._setupOverlayCtrl();
this._initialInheritsReferenceWidth = this._overlayCtrl.inheritsReferenceWidth;
@ -342,6 +352,9 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this._overlayCtrl.addEventListener('hide', this.__overlayOnHide);
}
/**
* @enhance OverlayMixin
*/
_teardownOverlayCtrl() {
super._teardownOverlayCtrl();
this._overlayCtrl.removeEventListener('show', this.__overlayOnShow);
@ -357,14 +370,14 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
}
/**
* @override Configures OverlayMixin
* @configure OverlayMixin
*/
get _overlayInvokerNode() {
return this._invokerNode;
}
/**
* @override Configures OverlayMixin
* @configure OverlayMixin
*/
get _overlayContentNode() {
return this._listboxNode;
@ -388,7 +401,9 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
ev.preventDefault();
if (this.navigateWithinInvoker) {
this.setCheckedIndex(this._getPreviousEnabledOption(this.checkedIndex));
this.setCheckedIndex(
this._getPreviousEnabledOption(/** @type {number} */ (this.checkedIndex)),
);
} else {
this.opened = true;
}
@ -396,7 +411,9 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
case 'ArrowDown':
ev.preventDefault();
if (this.navigateWithinInvoker) {
this.setCheckedIndex(this._getNextEnabledOption(this.checkedIndex));
this.setCheckedIndex(
this._getNextEnabledOption(/** @type {number} */ (this.checkedIndex)),
);
} else {
this.opened = true;
}

View file

@ -5,16 +5,21 @@ const selector = 'lion-select-rich';
describe('forms-select-rich', () => {
it('main', async () => {
const id = `forms-select-rich--main`;
// @ts-expect-error
const page = await getStoryPage(id);
// @ts-expect-error
await capture({ selector, id, page });
});
it('main-opened', async () => {
const id = `forms-select-rich--main`;
// @ts-expect-error
const page = await getStoryPage(id);
await page.evaluate(() => {
const el = document.querySelector('lion-select-rich');
// @ts-expect-error
el.opened = true;
});
// @ts-expect-error
await capture({
selector,
id: `${id}-opened`,
@ -24,16 +29,21 @@ describe('forms-select-rich', () => {
});
it('options-with-html', async () => {
const id = `forms-select-rich--options-with-html`;
// @ts-expect-error
const page = await getStoryPage(id);
// @ts-expect-error
await capture({ selector, id, page });
});
it('options-with-html-opened', async () => {
const id = `forms-select-rich--options-with-html`;
// @ts-expect-error
const page = await getStoryPage(id);
await page.evaluate(() => {
const el = document.querySelector('lion-select-rich');
// @ts-expect-error
el.opened = true;
});
// @ts-expect-error
await capture({
selector,
id: `${id}-opened`,
@ -43,11 +53,14 @@ describe('forms-select-rich', () => {
});
it('many-options-with-scrolling-opened', async () => {
const id = `forms-select-rich--many-options-with-scrolling`;
// @ts-expect-error
const page = await getStoryPage(id);
await page.evaluate(() => {
const el = document.querySelector('lion-select-rich');
// @ts-expect-error
el.opened = true;
});
// @ts-expect-error
await capture({
selector,
id: `${id}-opened`,
@ -57,21 +70,28 @@ describe('forms-select-rich', () => {
});
it('read-only-prefilled', async () => {
const id = `forms-select-rich--read-only-prefilled`;
// @ts-expect-error
const page = await getStoryPage(id);
// @ts-expect-error
await capture({ selector, id, page });
});
it('disabled-select', async () => {
const id = `forms-select-rich--disabled-select`;
// @ts-expect-error
const page = await getStoryPage(id);
// @ts-expect-error
await capture({ selector, id, page });
});
it('disabled-option-opened', async () => {
const id = `forms-select-rich--disabled-option`;
// @ts-expect-error
const page = await getStoryPage(id);
await page.evaluate(() => {
const el = document.querySelector('lion-select-rich');
// @ts-expect-error
el.opened = true;
});
// @ts-expect-error
await capture({
selector,
id: `${id}-opened`,
@ -81,26 +101,35 @@ describe('forms-select-rich', () => {
});
it('validation', async () => {
const id = `forms-select-rich--validation`;
// @ts-expect-error
const page = await getStoryPage(id);
await page.evaluate(() => {
const el = document.querySelector('lion-select-rich');
// @ts-expect-error
el.updateComplete.then(() => {
// @ts-expect-error
el.touched = true;
// @ts-expect-error
el.dirty = true;
});
});
// @ts-expect-error
await capture({ selector, id, page });
});
it('render-options', async () => {
const id = `forms-select-rich--render-options`;
// @ts-expect-error
const page = await getStoryPage(id);
// @ts-expect-error
await capture({ selector, id, page });
});
it('interaction-mode-mac', async () => {
const id = `forms-select-rich--interaction-mode`;
// @ts-expect-error
const page = await getStoryPage(id);
await page.click('lion-select-rich');
await page.keyboard.press('ArrowDown');
// @ts-expect-error
await capture({
selector,
id: `${id}-mac`,
@ -109,9 +138,11 @@ describe('forms-select-rich', () => {
});
it('interaction-mode-windows-linux', async () => {
const id = `forms-select-rich--interaction-mode`;
// @ts-expect-error
const page = await getStoryPage(id);
await page.click('lion-select-rich:last-of-type');
await page.keyboard.press('ArrowDown');
// @ts-expect-error
await capture({
selector: 'lion-select-rich:last-of-type',
id: `${id}-windows-linux`,
@ -120,12 +151,16 @@ describe('forms-select-rich', () => {
});
it('no-default-selection', async () => {
const id = `forms-select-rich--no-default-selection`;
// @ts-expect-error
const page = await getStoryPage(id);
// @ts-expect-error
await capture({ selector, id, page });
});
it('single-option', async () => {
const id = `forms-select-rich--single-option`;
// @ts-expect-error
const page = await getStoryPage(id);
// @ts-expect-error
await capture({ selector, id, page });
});
});

View file

@ -10,10 +10,12 @@ describe('lion-select-invoker', () => {
});
it('renders invoker info based on selectedElement child elements', async () => {
const el = await fixture(html`<lion-select-invoker></lion-select-invoker>`);
el.selectedElement = await fixture(
const el = /** @type {LionSelectInvoker} */ (await fixture(
html`<lion-select-invoker></lion-select-invoker>`,
));
el.selectedElement = /** @type {HTMLElement} */ (await fixture(
`<div class="option">Textnode<h2>I am</h2><p>2 lines</p></div>`,
);
));
await el.updateComplete;
expect(el._contentWrapperNode).lightDom.to.equal(
@ -29,31 +31,40 @@ describe('lion-select-invoker', () => {
});
it('renders invoker info based on selectedElement textContent', async () => {
const el = await fixture(html`<lion-select-invoker></lion-select-invoker>`);
el.selectedElement = await fixture(`<div class="option">just textContent</div>`);
const el = /** @type {LionSelectInvoker} */ (await fixture(
html`<lion-select-invoker></lion-select-invoker>`,
));
el.selectedElement = /** @type {HTMLElement} */ (await fixture(
`<div class="option">just textContent</div>`,
));
await el.updateComplete;
expect(el._contentWrapperNode).lightDom.to.equal('just textContent');
});
it('has tabindex="0"', async () => {
const el = await fixture(html`<lion-select-invoker></lion-select-invoker>`);
const el = /** @type {LionSelectInvoker} */ (await fixture(
html`<lion-select-invoker></lion-select-invoker>`,
));
expect(el.tabIndex).to.equal(0);
expect(el.getAttribute('tabindex')).to.equal('0');
});
it('should not render after slot when singleOption is true', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectInvoker} */ (await fixture(html`
<lion-select-invoker .singleOption="${true}"></lion-select-invoker>
`);
`));
expect(el.shadowRoot.querySelector('slot[name="after"]')).to.not.exist;
expect(/** @type {ShadowRoot} */ (el.shadowRoot).querySelector('slot[name="after"]')).to.not
.exist;
});
it('should render after slot when singleOption is not true', async () => {
const el = await fixture(html`<lion-select-invoker></lion-select-invoker>`);
const el = /** @type {LionSelectInvoker} */ (await fixture(
html`<lion-select-invoker></lion-select-invoker>`,
));
expect(el.shadowRoot.querySelector('slot[name="after"]')).to.exist;
expect(/** @type {ShadowRoot} */ (el.shadowRoot).querySelector('slot[name="after"]')).to.exist;
});
describe('Subclassers', () => {
@ -68,13 +79,17 @@ describe('lion-select-invoker', () => {
}
},
);
const el = await fixture(`<${myTag}></${myTag}>`);
const el = /** @type {LionSelectInvoker} */ (await fixture(`<${myTag}></${myTag}>`));
el.selectedElement = await fixture(`<div class="option">cat</div>`);
el.selectedElement = /** @type {HTMLElement} */ (await fixture(
`<div class="option">cat</div>`,
));
await el.updateComplete;
expect(el._contentWrapperNode).lightDom.to.equal('cat selected');
el.selectedElement = await fixture(`<div class="option">dog</div>`);
el.selectedElement = /** @type {HTMLElement} */ (await fixture(
`<div class="option">dog</div>`,
));
await el.updateComplete;
expect(el._contentWrapperNode).lightDom.to.equal('no valid selection');
});

View file

@ -5,6 +5,10 @@ import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '../lion-select-rich.js';
/**
* @typedef {import('../src/LionSelectRich').LionSelectRich} LionSelectRich
*/
const tagString = defineCE(
class extends OverlayMixin(LitElement) {
render() {
@ -24,14 +28,14 @@ describe('Select Rich Integration tests', () => {
let properlyInstantiated = false;
try {
const nestedEl = await fixture(html`
const nestedEl = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
`));
await nestedEl.registrationComplete;
await fixture(html`

View file

@ -1,57 +1,82 @@
import { Required } from '@lion/form-core';
import { expect, html, triggerBlurFor, triggerFocusFor, fixture } from '@open-wc/testing';
import { browserDetection } from '@lion/core';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '../lion-select-rich.js';
/**
* @typedef {import('../src/LionSelectRich').LionSelectRich} LionSelectRich
* @typedef {import('@lion/listbox').LionOption} LionOption
*/
/**
* @param {LionSelectRich} lionSelectEl
*/
function getNodes(lionSelectEl) {
// @ts-ignore protected members allowed in test
const {
_invokerNode: invoker,
_feedbackNode: feedback,
_labelNode: label,
_helpTextNode: helpText,
_listboxNode: listbox,
} = lionSelectEl;
return {
invoker,
feedback,
label,
helpText,
listbox,
};
}
describe('lion-select-rich interactions', () => {
describe('Interaction mode', () => {
it('autodetects interactionMode if not defined', async () => {
const originalIsMac = browserDetection.isMac;
browserDetection.isMac = true;
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich>
`);
`));
expect(el.interactionMode).to.equal('mac');
const el2 = await fixture(html`
const el2 = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich interaction-mode="windows/linux"
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
>
`);
`));
expect(el2.interactionMode).to.equal('windows/linux');
browserDetection.isMac = false;
const el3 = await fixture(html`
const el3 = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich>
`);
`));
expect(el3.interactionMode).to.equal('windows/linux');
const el4 = await fixture(html`
const el4 = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich interaction-mode="mac"
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
>
`);
`));
expect(el4.interactionMode).to.equal('mac');
browserDetection.isMac = originalIsMac;
});
it('derives selectionFollowsFocus and navigateWithinInvoker from interactionMode', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich interaction-mode="windows/linux"
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
>
`);
`));
expect(el.selectionFollowsFocus).to.be.true;
expect(el.navigateWithinInvoker).to.be.true;
const el2 = await fixture(html`
const el2 = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich interaction-mode="mac"
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
>
`);
`));
expect(el2.selectionFollowsFocus).to.be.false;
expect(el2.navigateWithinInvoker).to.be.false;
});
@ -59,7 +84,15 @@ describe('lion-select-rich interactions', () => {
describe('Invoker Keyboard navigation Windows', () => {
it('navigates through list with [ArrowDown] [ArrowUp] keys checks the option while listbox unopened', async () => {
/**
* @param {LionOption[]} options
* @param {number} selectedIndex
*/
function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) {
/**
* @param {{ checked: any; }} option
* @param {any} i
*/
options.forEach((option, i) => {
if (i === selectedIndex) {
expect(option.checked).to.be.true;
@ -69,7 +102,7 @@ describe('lion-select-rich interactions', () => {
});
}
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich interaction-mode="windows/linux">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
@ -77,9 +110,9 @@ describe('lion-select-rich interactions', () => {
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
`));
const options = Array.from(el.querySelectorAll('lion-option'));
const options = el.formElements;
expect(el.checkedIndex).to.equal(0);
expectOnlyGivenOneOptionToBeChecked(options, 0);
@ -95,79 +128,83 @@ describe('lion-select-rich interactions', () => {
describe('Disabled', () => {
it('invoker cannot be focused if disabled', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich disabled>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
expect(el._invokerNode.tabIndex).to.equal(-1);
`));
const { invoker } = getNodes(el);
expect(invoker.tabIndex).to.equal(-1);
});
it('cannot be opened via click if disabled', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich disabled>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
el._invokerNode.click();
`));
const { invoker } = getNodes(el);
invoker.click();
expect(el.opened).to.be.false;
});
it('reflects disabled attribute to invoker', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich disabled>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
expect(el._invokerNode.hasAttribute('disabled')).to.be.true;
`));
const { invoker } = getNodes(el);
expect(invoker.hasAttribute('disabled')).to.be.true;
el.removeAttribute('disabled');
await el.updateComplete;
expect(el._invokerNode.hasAttribute('disabled')).to.be.false;
expect(invoker.hasAttribute('disabled')).to.be.false;
});
});
describe('Interaction states', () => {
it('becomes touched if blurred once', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
`));
const { invoker } = getNodes(el);
expect(el.touched).to.be.false;
await triggerFocusFor(el._invokerNode);
await triggerBlurFor(el._invokerNode);
await triggerFocusFor(invoker);
await triggerBlurFor(invoker);
expect(el.touched).to.be.true;
});
});
describe('Accessibility', () => {
it('sets [aria-invalid="true"] to "._invokerNode" when there is an error', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich .validators=${[new Required()]}>
<lion-options slot="input">
<lion-option .choiceValue=${null}>Please select a value</lion-option>
<lion-option .modelValue=${{ value: 10, checked: true }}>Item 1</lion-option>
</lion-options>
</lion-select-rich>
`);
const invokerNode = el._invokerNode;
const options = el.querySelectorAll('lion-option');
`));
const { invoker } = getNodes(el);
const options = el.formElements;
await el.feedbackComplete;
await el.updateComplete;
expect(invokerNode.getAttribute('aria-invalid')).to.equal('false');
expect(invoker.getAttribute('aria-invalid')).to.equal('false');
options[0].checked = true;
await el.feedbackComplete;
await el.updateComplete;
expect(invokerNode.getAttribute('aria-invalid')).to.equal('true');
expect(invoker.getAttribute('aria-invalid')).to.equal('true');
options[1].checked = true;
await el.feedbackComplete;
await el.updateComplete;
expect(invokerNode.getAttribute('aria-invalid')).to.equal('false');
expect(invoker.getAttribute('aria-invalid')).to.equal('false');
});
});
});

View file

@ -16,219 +16,297 @@ import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '../lion-select-rich.js';
/**
* @typedef {import('@lion/listbox').LionOption} LionOption
*/
/**
* @param {LionSelectRich} lionSelectEl
*/
function getProtectedMembers(lionSelectEl) {
// @ts-ignore protected members allowed in test
const {
_invokerNode: invoker,
_feedbackNode: feedback,
_labelNode: label,
_helpTextNode: helpText,
_listboxNode: listbox,
} = lionSelectEl;
return {
invoker,
feedback,
label,
helpText,
listbox,
};
}
describe('lion-select-rich', () => {
it('clicking the label should focus the invoker', async () => {
const el = await fixture(html` <lion-select-rich label="foo"> </lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich label="foo"> </lion-select-rich> `,
));
expect(document.activeElement === document.body).to.be.true;
el._labelNode.click();
// @ts-ignore allow protected access in tests
expect(document.activeElement === el._invokerNode).to.be.true;
});
it('checks the first enabled option', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich>
<lion-option .choiceValue=${'Red'}></lion-option>
<lion-option .choiceValue=${'Hotpink'}></lion-option>
<lion-option .choiceValue=${'Blue'}></lion-option>
</lion-select-rich>
`);
`));
expect(el.activeIndex).to.equal(0);
expect(el.checkedIndex).to.equal(0);
});
it('still has a checked value while disabled', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich disabled>
<lion-option .choiceValue=${'Red'}>Red</lion-option>
<lion-option .choiceValue=${'Hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'Blue'}>Blue</lion-option>
</lion-select-rich>
`);
`));
expect(el.modelValue).to.equal('Red');
});
it('supports having no default selection initially', async () => {
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich id="color" name="color" label="Favorite color" has-no-default-selected>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-select-rich>
`));
const { invoker } = getProtectedMembers(el);
expect(invoker.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` <lion-select-rich> </lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich> </lion-select-rich> `,
));
// @ts-ignore allow protected access in tests
expect(el._invokerNode).to.exist;
// @ts-ignore allow protected access in tests
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`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich name="foo">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
const options = Array.from(el.querySelectorAll('lion-option'));
`));
const options = el.formElements;
// @ts-ignore allow protected access in tests
expect(el._invokerNode.selectedElement).dom.to.equal(options[0]);
});
it('syncs the selected element to the invoker', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich name="foo">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-select-rich>
`);
`));
const options = el.querySelectorAll('lion-option');
// @ts-ignore allow protected access in tests
expect(el._invokerNode.selectedElement).dom.to.equal(options[1]);
el.checkedIndex = 0;
await el.updateComplete;
// @ts-ignore allow protected access in tests
expect(el._invokerNode.selectedElement).dom.to.equal(options[0]);
});
it('delegates readonly to the invoker', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich readonly>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
`));
expect(el.hasAttribute('readonly')).to.be.true;
// @ts-ignore allow protected access in tests
expect(el._invokerNode.hasAttribute('readonly')).to.be.true;
});
it('delegates singleOption to the invoker', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich>
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-select-rich>
`);
`));
expect(el.singleOption).to.be.true;
// @ts-ignore allow protected access in tests
expect(el._invokerNode.hasAttribute('single-option')).to.be.true;
});
});
describe('overlay', () => {
it('should be closed by default', async () => {
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich></lion-select-rich> `,
));
expect(el.opened).to.be.false;
});
it('shows/hides the listbox via opened attribute', async () => {
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich></lion-select-rich> `,
));
el.opened = true;
await el.updateComplete;
// @ts-ignore allow protected access in tests
expect(el._overlayCtrl.isShown).to.be.true;
el.opened = false;
await el.updateComplete;
// @ts-ignore allow protected access in tests
expect(el._overlayCtrl.isShown).to.be.false;
});
it('syncs opened state with overlay shown', async () => {
const el = await fixture(html` <lion-select-rich .opened=${true}></lion-select-rich> `);
const outerEl = await fixture('<button>somewhere</button>');
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich .opened=${true}></lion-select-rich> `,
));
const outerEl = /** @type {HTMLButtonElement} */ (await fixture(
'<button>somewhere</button>',
));
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();
await aTimeout(0);
expect(el.opened).to.be.false;
});
it('will focus the listbox on open and invoker on close', async () => {
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich></lion-select-rich> `,
));
// @ts-ignore allow protected access in tests
await el._overlayCtrl.show();
await el.updateComplete;
// @ts-ignore allow protected access in tests
expect(document.activeElement === el._listboxNode).to.be.true;
// @ts-ignore allow protected access in tests
expect(document.activeElement === el._invokerNode).to.be.false;
el.opened = false;
await el.updateComplete;
// @ts-ignore allow protected access in tests
expect(document.activeElement === el._listboxNode).to.be.false;
// @ts-ignore allow protected access in tests
expect(document.activeElement === el._invokerNode).to.be.true;
});
it('opens the listbox with checked option as active', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-select-rich>
`);
`));
// @ts-ignore allow protected access in tests
await el._overlayCtrl.show();
await el.updateComplete;
const options = Array.from(el.querySelectorAll('lion-option'));
const options = el.formElements;
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`
const elReadOnly = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich readonly>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-select-rich>
`);
`));
const elDisabled = await fixture(html`
const elDisabled = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich disabled>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-select-rich>
`);
`));
const elSingleoption = await fixture(html`
const elSingleoption = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich>
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-select-rich>
`);
`));
// @ts-ignore allow protected access in tests
elReadOnly._invokerNode.click();
await elReadOnly.updateComplete;
expect(elReadOnly.opened).to.be.false;
// @ts-ignore allow protected access in tests
elDisabled._invokerNode.click();
await elDisabled.updateComplete;
expect(elDisabled.opened).to.be.false;
// @ts-ignore allow protected access in tests
elSingleoption._invokerNode.click();
await elSingleoption.updateComplete;
expect(elSingleoption.opened).to.be.false;
});
it('sets inheritsReferenceWidth to min by default', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich name="favoriteColor" label="Favorite color">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-select-rich>
`);
`));
// @ts-ignore allow protected access in tests
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('min');
el.opened = true;
await el.updateComplete;
// @ts-ignore allow protected access in tests
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('min');
});
it('should override the inheritsWidth prop when no default selected feature is used', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich name="favoriteColor" label="Favorite color" has-no-default-selected>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-select-rich>
`);
`));
// The default is min, so we override that behavior here
// @ts-ignore allow protected access in tests
el._overlayCtrl.updateConfig({ inheritsReferenceWidth: 'full' });
el._initialInheritsReferenceWidth = 'full';
// @ts-ignore allow protected access in tests
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full');
el.opened = true;
await el.updateComplete;
// Opens while hasNoDefaultSelected = true, so we expect an override
// @ts-ignore allow protected access in tests
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('min');
// Emulate selecting hotpink, it closing, and opening it again
@ -239,69 +317,86 @@ describe('lion-select-rich', () => {
await el.updateComplete;
// noDefaultSelected will now flip the override back to what was the initial reference width
// @ts-ignore allow protected access in tests
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full');
});
it('should have singleOption only if there is exactly one option', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
`));
expect(el.singleOption).to.be.false;
// @ts-ignore allow protected access in tests
expect(el._invokerNode.singleOption).to.be.false;
const optionELm = el.querySelectorAll('lion-option')[0];
const optionELm = el.formElements[0];
// @ts-ignore allow protected access in tests
optionELm.parentNode.removeChild(optionELm);
el.requestUpdate();
await el.updateComplete;
expect(el.singleOption).to.be.true;
// @ts-ignore allow protected access in tests
expect(el._invokerNode.singleOption).to.be.true;
const newOption = document.createElement('lion-option');
const newOption = /** @type {LionOption} */ (document.createElement('lion-option'));
newOption.choiceValue = 30;
el._inputNode.appendChild(newOption);
el.requestUpdate();
await el.updateComplete;
expect(el.singleOption).to.be.false;
// @ts-ignore allow protected access in tests
expect(el._invokerNode.singleOption).to.be.false;
});
});
describe('interaction-mode', () => {
it('allows to specify an interaction-mode which determines other behaviors', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
`);
`));
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` <lion-select-rich> </lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich> </lion-select-rich> `,
));
// @ts-ignore allow protected access in tests
el._invokerNode.click();
await aTimeout();
await aTimeout(0);
expect(el.opened).to.be.true;
});
it('opens the listbox with [ ](Space) key via click handler', async () => {
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich> </lion-select-rich> `,
));
// @ts-ignore allow protected access in tests
el._invokerNode.click();
await aTimeout();
await aTimeout(0);
expect(el.opened).to.be.true;
});
it('closes the listbox with [Escape] key once opened', async () => {
const el = await fixture(html` <lion-select-rich opened> </lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich opened> </lion-select-rich> `,
));
// @ts-ignore allow protected access in tests
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(el.opened).to.be.false;
});
it('closes the listbox with [Tab] key once opened', async () => {
const el = await fixture(html` <lion-select-rich opened> </lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich opened> </lion-select-rich> `,
));
// tab can only be caught via keydown
// @ts-ignore allow protected access in tests
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
expect(el.opened).to.be.false;
});
@ -309,28 +404,34 @@ describe('lion-select-rich', () => {
describe('Mouse navigation', () => {
it('opens the listbox via click on invoker', async () => {
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich> </lion-select-rich> `,
));
expect(el.opened).to.be.false;
// @ts-ignore allow protected access in tests
el._invokerNode.click();
await nextFrame(); // reflection of click takes some time
expect(el.opened).to.be.true;
});
it('closes the listbox when an option gets clicked', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich opened>
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-select-rich>
`);
`));
expect(el.opened).to.be.true;
el.querySelector('lion-option').click();
el.formElements[0].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` <lion-select-rich opened> </lion-select-rich> `);
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich opened> </lion-select-rich> `,
));
// @ts-ignore allow protected access in tests
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.opened).to.be.false;
});
@ -338,35 +439,35 @@ describe('lion-select-rich', () => {
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`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich opened interaction-mode="mac">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
`));
// changes active but not checked
el.activeIndex = 1;
expect(el.checkedIndex).to.equal(0);
// @ts-ignore allow protected access in tests
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { 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`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
`);
`));
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`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
`);
`));
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
await el.updateComplete;
expect(el.opened).to.be.true;
@ -375,34 +476,41 @@ describe('lion-select-rich', () => {
describe('Accessibility', () => {
it('has the right references to its inner elements', async () => {
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<lion-select-rich label="age">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
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');
`));
const { invoker, feedback, label, helpText } = getProtectedMembers(el);
expect(invoker.getAttribute('aria-labelledby')).to.contain(label.id);
expect(invoker.getAttribute('aria-labelledby')).to.contain(invoker.id);
expect(invoker.getAttribute('aria-describedby')).to.contain(helpText.id);
expect(invoker.getAttribute('aria-describedby')).to.contain(feedback.id);
expect(invoker.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` <lion-select-rich> </lion-select-rich> `);
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false');
const el = /** @type {LionSelectRich} */ (await fixture(
html` <lion-select-rich> </lion-select-rich> `,
));
const { invoker } = getProtectedMembers(el);
expect(invoker.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');
expect(invoker.getAttribute('aria-expanded')).to.equal('true');
});
});
describe('Use cases', () => {
it('keeps showing the selected item after a new item has been added in the selectedIndex position', async () => {
const mySelectContainerTagString = defineCE(
// @ts-expect-error
class extends LitElement {
static get properties() {
return {
@ -447,26 +555,29 @@ describe('lion-select-rich', () => {
},
);
const mySelectContainerTag = unsafeStatic(mySelectContainerTagString);
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<${mySelectContainerTag}></${mySelectContainerTag}>
`);
`));
const selectRich = el.shadowRoot.querySelector('lion-select-rich');
const invoker = selectRich._invokerNode;
const selectRich = /** @type {LionSelectRich} */ (
/** @type {ShadowRoot} */ (el.shadowRoot).querySelector('lion-select-rich')
);
const { invoker, listbox } = getProtectedMembers(selectRich);
expect(selectRich.checkedIndex).to.equal(1);
expect(selectRich.modelValue).to.equal('hotpink');
expect(invoker.selectedElement.value).to.equal('hotpink');
expect(/** @type {LionOption} */ (invoker.selectedElement).value).to.equal('hotpink');
const newOption = document.createElement('lion-option');
const newOption = /** @type {LionOption} */ (document.createElement('lion-option'));
newOption.modelValue = { checked: false, value: 'blue' };
newOption.textContent = 'Blue';
const hotpinkEl = selectRich._listboxNode.children[1];
const hotpinkEl = listbox.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');
expect(/** @type {LionOption} */ (invoker.selectedElement).value).to.equal('hotpink');
});
});
@ -474,6 +585,7 @@ describe('lion-select-rich', () => {
it('allows to override the type of overlay', async () => {
const mySelectTagString = defineCE(
class MySelect extends LionSelectRich {
// @ts-expect-error
_defineOverlay({ invokerNode, contentNode, contentWrapperNode }) {
const ctrl = new OverlayController({
placementMode: 'global',
@ -491,7 +603,7 @@ describe('lion-select-rich', () => {
const mySelectTag = unsafeStatic(mySelectTagString);
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<${mySelectTag} label="Favorite color" name="color">
${Array(2).map(
@ -501,10 +613,12 @@ describe('lion-select-rich', () => {
)}
</${mySelectTag}>
`);
`));
await el.updateComplete;
// @ts-ignore allow protected member access in tests
expect(el._overlayCtrl.placementMode).to.equal('global');
el.dispatchEvent(new Event('switch'));
// @ts-ignore allow protected member access in tests
expect(el._overlayCtrl.placementMode).to.equal('local');
});
@ -530,7 +644,7 @@ describe('lion-select-rich', () => {
);
const selectTag = unsafeStatic(selectTagName);
const el = await fixture(html`
const el = /** @type {LionSelectRich} */ (await fixture(html`
<${selectTag} id="color" name="color" label="Favorite color" has-no-default-selected>
<lion-option .choiceValue=${'red'}>Red</lion-option>
@ -538,11 +652,12 @@ describe('lion-select-rich', () => {
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</${selectTag}>
`);
`));
const { invoker } = getProtectedMembers(el);
expect(el._invokerNode.shadowRoot.getElementById('content-wrapper')).dom.to.equal(
`<div id="content-wrapper">Please select an option..</div>`,
);
expect(
/** @type {ShadowRoot} */ (invoker.shadowRoot).getElementById('content-wrapper'),
).dom.to.equal(`<div id="content-wrapper">Please select an option..</div>`);
expect(el.modelValue).to.equal('');
});
});

View file

@ -18,6 +18,7 @@
"packages/accordion/**/*.js",
"packages/button/src/**/*.js",
"packages/calendar/**/*.js",
"packages/button/index.js",
"packages/checkbox-group/**/*.js",
"packages/collapsible/**/*.js",
"packages/core/**/*.js",
@ -33,13 +34,14 @@
"packages/input-iban/**/*.js",
"packages/input-range/**/*.js",
"packages/input-stepper/**/*.js",
"packages/listbox/src/*.js",
"packages/listbox/**/*.js",
"packages/localize/**/*.js",
"packages/overlays/**/*.js",
"packages/pagination/**/*.js",
"packages/progress-indicator/**/*.js",
"packages/radio-group/**/*.js",
"packages/select/**/*.js",
"packages/select-rich/**/*.js",
"packages/singleton-manager/**/*.js",
"packages/steps/**/*.js",
"packages/switch/**/*.js",