1328 lines
53 KiB
JavaScript
1328 lines
53 KiB
JavaScript
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
|
import { Required } from '@lion/form-core';
|
|
import { LionOptions } from '@lion/listbox';
|
|
import '@lion/listbox/lion-option.js';
|
|
import '@lion/listbox/lion-options.js';
|
|
import { expect, fixture as _fixture, html, unsafeStatic } from '@open-wc/testing';
|
|
import sinon from 'sinon';
|
|
import '../lion-listbox.js';
|
|
|
|
/**
|
|
* @typedef {import('../src/LionListbox').LionListbox} LionListbox
|
|
* @typedef {import('../src/LionOption').LionOption} LionOption
|
|
* @typedef {import('@lion/select-rich').LionSelectInvoker} LionSelectInvoker
|
|
* @typedef {import('@lion/core').TemplateResult} TemplateResult
|
|
*/
|
|
|
|
const fixture = /** @type {(arg: TemplateResult) => Promise<LionListbox>} */ (_fixture);
|
|
|
|
/**
|
|
* @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 = {
|
|
tagString: 'lion-listbox',
|
|
optionTagString: 'lion-option',
|
|
...customConfig,
|
|
};
|
|
|
|
const tag = unsafeStatic(cfg.tagString);
|
|
const optionTag = unsafeStatic(cfg.optionTagString);
|
|
|
|
describe('ListboxMixin', () => {
|
|
// TODO: See if it is possible to get functionality from ChoiceGroup and/or Field suite(s)
|
|
describe('FormControl (Field / ChoiceGroup) functionality', () => {
|
|
it('has a single modelValue representing the currently checked option', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="foo">
|
|
<${optionTag} .choiceValue=${'10'} checked>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
expect(el.modelValue).to.equal('10');
|
|
});
|
|
|
|
it('should dispatch model-value-changed 1 time on first paint', async () => {
|
|
const spy = sinon.spy();
|
|
await fixture(html`
|
|
<${tag} @model-value-changed="${spy}">
|
|
<${optionTag} .choiceValue="${'10'}">Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue="${'20'}">Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue="${'30'}">Item 3</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(spy.callCount).to.equal(1);
|
|
});
|
|
|
|
it('should dispatch model-value-changed 1 time on interaction', async () => {
|
|
const spy = sinon.spy();
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue="${'10'}">Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue="${'20'}">Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue="${'30'}">Item 3</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
el.addEventListener('model-value-changed', spy);
|
|
el.formElements[1].checked = true;
|
|
expect(spy.callCount).to.equal(1);
|
|
|
|
spy.resetHistory();
|
|
|
|
el.formElements[2].checked = true;
|
|
expect(spy.callCount).to.equal(1);
|
|
});
|
|
|
|
it('should not dispatch model-value-changed on reappend checked child', async () => {
|
|
const spy = sinon.spy();
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue="${'10'}">Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue="${'20'}">Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue="${'30'}">Item 3</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
el.addEventListener('model-value-changed', spy);
|
|
el.formElements[1].checked = true;
|
|
expect(spy.callCount).to.equal(1);
|
|
|
|
el.appendChild(el.formElements[1]);
|
|
expect(spy.callCount).to.equal(1);
|
|
});
|
|
|
|
it('automatically sets the name attribute of child checkboxes to its own name', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="foo">
|
|
<${optionTag} .choiceValue=${10} checked>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
expect(el.formElements[0].name).to.equal('foo');
|
|
expect(el.formElements[1].name).to.equal('foo');
|
|
|
|
const validChild = await fixture(
|
|
html` <${optionTag} .choiceValue=${30}>Item 3</${optionTag}> `,
|
|
);
|
|
el.appendChild(validChild);
|
|
|
|
expect(el.formElements[2].name).to.equal('foo');
|
|
});
|
|
|
|
it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="foo">
|
|
<${optionTag} .choiceValue=${10} checked>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const invalidChild = await fixture(
|
|
html` <${optionTag} .modelValue=${'Lara'}></${optionTag}> `,
|
|
);
|
|
|
|
expect(() => {
|
|
el.addFormElement(invalidChild);
|
|
}).to.throw(
|
|
`The ${cfg.tagString} name="foo" does not allow to register lion-option with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }`,
|
|
);
|
|
});
|
|
|
|
it('can set initial modelValue on creation', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="gender" .modelValue=${'other'}>
|
|
<${optionTag} .choiceValue=${'male'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'female'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'other'}></${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
expect(el.modelValue).to.equal('other');
|
|
expect(el.formElements[2].checked).to.be.true;
|
|
});
|
|
|
|
it('requests update for modelValue when checkedIndex changes', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="gender" .modelValue=${'other'}>
|
|
<${optionTag} .choiceValue=${'male'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'female'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'other'}></${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.checkedIndex).to.equal(2);
|
|
const requestSpy = sinon.spy(el, 'requestUpdate');
|
|
const updatedSpy = sinon.spy(el, 'updated');
|
|
el.setCheckedIndex(1);
|
|
await el.updateComplete;
|
|
expect(requestSpy).to.have.been.calledWith('modelValue', 'other');
|
|
expect(updatedSpy).to.have.been.calledWith(
|
|
sinon.match.map.contains(new Map([['modelValue', 'other']])),
|
|
);
|
|
});
|
|
|
|
it('requests update for modelValue after click', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="gender" .modelValue=${'other'}>
|
|
<${optionTag} .choiceValue=${'male'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'female'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'other'}></${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.checkedIndex).to.equal(2);
|
|
const requestSpy = sinon.spy(el, 'requestUpdate');
|
|
const updatedSpy = sinon.spy(el, 'updated');
|
|
el.formElements[0].click();
|
|
await el.updateComplete;
|
|
expect(requestSpy).to.have.been.calledWith('modelValue', 'other');
|
|
expect(updatedSpy).to.have.been.calledWith(
|
|
sinon.match.map.contains(new Map([['modelValue', 'other']])),
|
|
);
|
|
});
|
|
|
|
it('requests update for modelValue when checkedIndex changes for multiple choice', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="gender" multiple-choice .modelValue=${['other']}>
|
|
<${optionTag} .choiceValue=${'male'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'female'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'other'}></${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.checkedIndex).to.eql([2]);
|
|
const requestSpy = sinon.spy(el, 'requestUpdate');
|
|
const updatedSpy = sinon.spy(el, 'updated');
|
|
el.setCheckedIndex(1);
|
|
await el.updateComplete;
|
|
expect(requestSpy).to.have.been.calledWith(
|
|
'modelValue',
|
|
sinon.match.array.deepEquals(['other']),
|
|
);
|
|
expect(updatedSpy).to.have.been.calledOnce;
|
|
// reference values vs real values suck :( had to do it like this, sinon matchers did not match because 'other' is inside an array so it's not a "real" match
|
|
expect([...updatedSpy.args[0][0].entries()]).to.deep.include(['modelValue', ['other']]);
|
|
});
|
|
|
|
it('requests update for modelValue after click for multiple choice', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="gender" multiple-choice .modelValue=${['other']}>
|
|
<${optionTag} .choiceValue=${'male'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'female'}></${optionTag}>
|
|
<${optionTag} .choiceValue=${'other'}></${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.checkedIndex).to.eql([2]);
|
|
const requestSpy = sinon.spy(el, 'requestUpdate');
|
|
const updatedSpy = sinon.spy(el, 'updated');
|
|
el.formElements[0].click();
|
|
await el.updateComplete;
|
|
expect(requestSpy).to.have.been.calledWith(
|
|
'modelValue',
|
|
sinon.match.array.deepEquals(['other']),
|
|
);
|
|
expect(updatedSpy).to.have.been.calledOnce;
|
|
// reference values vs real values suck :( had to do it like this, sinon matchers did not match because 'other' is inside an array so it's not a "real" match
|
|
expect([...updatedSpy.args[0][0].entries()]).to.deep.include(['modelValue', ['other']]);
|
|
});
|
|
|
|
it(`has a fieldName based on the label`, async () => {
|
|
const el1 = await fixture(html`
|
|
<${tag} label="foo"></${tag}>
|
|
`);
|
|
expect(el1.fieldName).to.equal(el1._labelNode.textContent);
|
|
|
|
const el2 = await fixture(html`
|
|
<${tag}>
|
|
<label slot="label">bar</label>
|
|
</${tag}>
|
|
`);
|
|
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`
|
|
<${tag} name="foo"></${tag}>
|
|
`);
|
|
expect(el.fieldName).to.equal(el.name);
|
|
});
|
|
|
|
it(`can override fieldName`, async () => {
|
|
const el = await fixture(html`
|
|
<${tag} label="foo" .fieldName="${'bar'}"></${tag}>
|
|
`);
|
|
expect(el.__fieldName).to.equal(el.fieldName);
|
|
});
|
|
|
|
it('supports validation', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}
|
|
id="color"
|
|
name="color"
|
|
label="Favorite color"
|
|
.validators="${[new Required()]}"
|
|
>
|
|
<${optionTag} .choiceValue=${null}>select a color</${optionTag}>
|
|
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
|
|
<${optionTag} .choiceValue=${'hotpink'} disabled>Hotpink</${optionTag}>
|
|
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
expect(el.hasFeedbackFor.includes('error')).to.be.true;
|
|
expect(el.showsFeedbackFor.includes('error')).to.be.false;
|
|
|
|
// test submitted prop explicitly, since we dont extend field, we add the prop manually
|
|
el.submitted = true;
|
|
await el.updateComplete;
|
|
expect(el.showsFeedbackFor.includes('error')).to.be.true;
|
|
|
|
el.formElements[1].checked = true;
|
|
await el.updateComplete;
|
|
expect(el.hasFeedbackFor.includes('error')).to.be.false;
|
|
expect(el.showsFeedbackFor.includes('error')).to.be.false;
|
|
|
|
el.formElements[0].checked = true;
|
|
await el.updateComplete;
|
|
expect(el.hasFeedbackFor.includes('error')).to.be.true;
|
|
expect(el.showsFeedbackFor.includes('error')).to.be.true;
|
|
});
|
|
});
|
|
|
|
describe('Selection', () => {
|
|
it('supports changing the selection through serializedValue setter', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} id="color" name="color" label="Favorite color" has-no-default-selected>
|
|
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
|
|
<${optionTag} .choiceValue=${'hotpink'}>Hotpink</${optionTag}>
|
|
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
expect(el.checkedIndex).to.equal(-1);
|
|
expect(el.serializedValue).to.equal('');
|
|
|
|
el.serializedValue = 'hotpink';
|
|
|
|
expect(el.checkedIndex).to.equal(1);
|
|
expect(el.serializedValue).to.equal('hotpink');
|
|
});
|
|
});
|
|
|
|
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">
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
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;
|
|
});
|
|
|
|
it('creates unique ids for all children', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20} selected>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${30} id="predefined">Item 3</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.querySelectorAll('lion-option')[0].id).to.exist;
|
|
expect(el.querySelectorAll('lion-option')[1].id).to.exist;
|
|
expect(el.querySelectorAll('lion-option')[2].id).to.equal('predefined');
|
|
});
|
|
|
|
it('has a reference to the active option', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} opened has-no-default-selected autocomplete="none">
|
|
<${optionTag} .choiceValue=${'10'} id="first">Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'20'} checked id="second">Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { activeDescendantOwner } = getProtectedMembers(el);
|
|
|
|
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.be.null;
|
|
await el.updateComplete;
|
|
|
|
// Normalize
|
|
el.activeIndex = 0;
|
|
await el.updateComplete;
|
|
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('first');
|
|
activeDescendantOwner.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
await el.updateComplete;
|
|
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('second');
|
|
});
|
|
|
|
it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} autocomplete="none">
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
el.formElements.forEach(optionEl => {
|
|
expect(optionEl.getAttribute('aria-setsize')).to.equal('3');
|
|
});
|
|
});
|
|
|
|
it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} autocomplete="none">
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
el.formElements.forEach((oEl, i) => {
|
|
expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Values', () => {
|
|
// TODO: ChoiceGroup suite?
|
|
it('registers options', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.formElements.length).to.equal(2);
|
|
expect(el.formElements).to.eql([
|
|
el.querySelectorAll('lion-option')[0],
|
|
el.querySelectorAll('lion-option')[1],
|
|
]);
|
|
});
|
|
|
|
it('allows null choiceValue', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} checked .choiceValue=${null}>Please select value</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
// @ts-ignore feature detect LionCombobox
|
|
if (el._comboboxNode) {
|
|
// note that the modelValue can only be supplied as string if we have a textbox
|
|
// (parsers not supported atm)
|
|
expect(el.modelValue).to.equal('');
|
|
return;
|
|
}
|
|
expect(el.modelValue).to.be.null;
|
|
});
|
|
|
|
it('should reset selected value to initial value', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'20'} checked>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.modelValue).to.equal('20');
|
|
el.setCheckedIndex(0);
|
|
expect(el.modelValue).to.equal('10');
|
|
el.reset();
|
|
expect(el.modelValue).to.equal('20');
|
|
});
|
|
|
|
it('should reset selected value to "Please select account" with has-no-default-selected attribute', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} has-no-default-selected>
|
|
<${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
el.setCheckedIndex(1);
|
|
expect(el.modelValue).to.equal('20');
|
|
el.reset();
|
|
expect(el.modelValue).to.equal('');
|
|
});
|
|
|
|
it('should reset selected value to initial values when multiple-choice', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} multiple-choice has-no-default-selected>
|
|
<${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'20'} checked>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${'30'} checked>Item 3</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
el.setCheckedIndex(0);
|
|
expect(el.modelValue).to.deep.equal(['10', '20', '30']);
|
|
el.reset();
|
|
expect(el.modelValue).to.deep.equal(['20', '30']);
|
|
});
|
|
|
|
it('has an activeIndex', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} has-no-default-selected autocomplete="list">
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const options = el.formElements;
|
|
|
|
expect(el.activeIndex).to.equal(-1);
|
|
options[1].active = true;
|
|
expect(options[0].active).to.be.false;
|
|
expect(el.activeIndex).to.equal(1);
|
|
});
|
|
|
|
it('should reset activeIndex value', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} has-no-default-selected>
|
|
<${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const options = el.formElements;
|
|
|
|
expect(el.activeIndex).to.equal(-1);
|
|
options[1].active = true;
|
|
expect(el.activeIndex).to.equal(1);
|
|
el.reset();
|
|
expect(el.activeIndex).to.equal(-1);
|
|
});
|
|
});
|
|
|
|
describe('Interactions', () => {
|
|
describe('Keyboard navigation', () => {
|
|
describe('Rotate Keyboard Navigation', () => {
|
|
it('stops navigation by default at end of option list', async () => {
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened name="foo" .rotateKeyboardNavigation="${false}">
|
|
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
// Normalize
|
|
el.activeIndex = 0;
|
|
const options = el.formElements;
|
|
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;
|
|
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;
|
|
});
|
|
|
|
it('when "rotate-navigation" provided, selects first option after navigated to next from last and vice versa', async () => {
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened name="foo" rotate-keyboard-navigation autocomplete="inline">
|
|
<${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
|
|
el._inputNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
|
|
await el.updateComplete;
|
|
// Normalize
|
|
el.activeIndex = 0;
|
|
expect(el.activeIndex).to.equal(0);
|
|
|
|
el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
|
|
await el.updateComplete;
|
|
expect(el.activeIndex).to.equal(2);
|
|
|
|
el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(el.activeIndex).to.equal(0);
|
|
// Extra check: regular navigation
|
|
el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(el.activeIndex).to.equal(1);
|
|
});
|
|
});
|
|
|
|
describe('Enter', () => {
|
|
it('[Enter] selects active option', async () => {
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened name="foo" autocomplete="none">
|
|
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
// Normalize suite
|
|
el.activeIndex = 0;
|
|
const options = el.formElements;
|
|
el.checkedIndex = 0;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
expect(options[1].checked).to.be.true;
|
|
});
|
|
});
|
|
|
|
describe('Space', () => {
|
|
it('selects active option when "_listboxReceivesNoFocus" is true', async () => {
|
|
// When listbox is not focusable (in case of a combobox), the user should be allowed
|
|
// to enter a space in the focusable element (texbox)
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened name="foo" ._listboxReceivesNoFocus="${false}" autocomplete="none">
|
|
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
// Normalize suite
|
|
el.activeIndex = 0;
|
|
const options = el.formElements;
|
|
el.checkedIndex = 0;
|
|
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;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
|
|
expect(options[1].checked).to.be.false;
|
|
});
|
|
});
|
|
// TODO: add key combinations like shift+home/ctrl+A etc etc.
|
|
// TODO: nice to have. Get from menu impl.
|
|
it.skip('selects a value with single [character] key', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} opened>
|
|
<${optionTag} .choiceValue=${'a'}>A</${optionTag}>
|
|
<${optionTag} .choiceValue=${'b'}>B</${optionTag}>
|
|
<${optionTag} .choiceValue=${'c'}>C</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.modelValue).to.equal('a');
|
|
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' }));
|
|
expect(el.modelValue).to.equal('c');
|
|
});
|
|
it.skip('selects a value with multiple [character] keys', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} opened>
|
|
<${optionTag} .choiceValue=${'bar'}>Bar</${optionTag}>
|
|
<${optionTag} .choiceValue=${'far'}>Far</${optionTag}>
|
|
<${optionTag} .choiceValue=${'foo'}>Foo</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F' }));
|
|
expect(el.modelValue).to.equal('far');
|
|
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' }));
|
|
expect(el.modelValue).to.equal('foo');
|
|
});
|
|
it('navigates to first and last option with [Home] and [End] keys', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} opened>
|
|
<${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${'30'} checked>Item 3</${optionTag}>
|
|
<${optionTag} .choiceValue=${'40'}>Item 4</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
// @ts-ignore allow protected members in tests
|
|
if (el._listboxReceivesNoFocus) {
|
|
return;
|
|
}
|
|
|
|
el.activeIndex = 2;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
|
|
expect(el.activeIndex).to.equal(0);
|
|
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 () => {
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened has-no-default-selected autocomplete="none">
|
|
<${optionTag} .choiceValue=${'Item 1'}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'Item 2'}>Item 2</${optionTag}>
|
|
<${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),
|
|
// but should still be able to work with selectionFollowsFocus=false
|
|
el.selectionFollowsFocus = false;
|
|
expect(el.activeIndex).to.equal(0);
|
|
expect(el.checkedIndex).to.equal(-1);
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(el.activeIndex).to.equal(1);
|
|
expect(el.checkedIndex).to.equal(-1);
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
|
|
expect(el.activeIndex).to.equal(0);
|
|
expect(el.checkedIndex).to.equal(-1);
|
|
});
|
|
});
|
|
|
|
describe('Orientation', () => {
|
|
it('has a default value of "vertical"', async () => {
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened name="foo" autocomplete="none">
|
|
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
expect(el.orientation).to.equal('vertical');
|
|
const options = el.formElements;
|
|
// Normalize for suite tests
|
|
el.activeIndex = 0;
|
|
|
|
await el.updateComplete;
|
|
expect(options[0].active).to.be.true;
|
|
expect(options[1].active).to.be.false;
|
|
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(options[0].active).to.be.false;
|
|
expect(options[1].active).to.be.true;
|
|
|
|
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...
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
|
expect(options[0].active).to.be.true;
|
|
expect(options[1].active).to.be.false;
|
|
|
|
el.activeIndex = 1;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
|
expect(options[0].active).to.be.false;
|
|
expect(options[1].active).to.be.true;
|
|
});
|
|
|
|
it('uses [ArrowLeft] and [ArrowRight] keys when "horizontal"', async () => {
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened name="foo" orientation="horizontal" autocomplete="none">
|
|
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
expect(el.orientation).to.equal('horizontal');
|
|
|
|
// Normalize for suite tests
|
|
el.activeIndex = 0;
|
|
|
|
await el.updateComplete;
|
|
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
|
expect(el.activeIndex).to.equal(1);
|
|
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
|
expect(el.activeIndex).to.equal(0);
|
|
|
|
// No response to vertical arrows...
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(el.activeIndex).to.equal(0);
|
|
|
|
el.activeIndex = 1;
|
|
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 = await fixture(html`
|
|
<${tag} name="foo" orientation="horizontal">
|
|
<${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { listbox } = getProtectedMembers(el);
|
|
expect(listbox.getAttribute('aria-orientation')).to.equal('horizontal');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Multiple Choice', () => {
|
|
it('does not uncheck siblings', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="foo" multiple-choice>
|
|
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chicory'}">Chicory</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Victoria Plum'}">Victoria Plum</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const options = el.formElements;
|
|
options[0].checked = true;
|
|
options[1].checked = true;
|
|
expect(options[0].checked).to.equal(true);
|
|
expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
|
|
});
|
|
|
|
it('works via different interaction mechanisms (click, enter, spaces)', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="foo" multiple-choice>
|
|
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chicory'}">Chicory</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Victoria Plum'}">Victoria Plum</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { listbox } = getProtectedMembers(el);
|
|
const options = el.formElements;
|
|
|
|
// @ts-ignore feature detection select-rich
|
|
if (el.navigateWithinInvoker !== undefined) {
|
|
// Note we don't have multipleChoice in the select-rich yet.
|
|
// TODO: implement in future when requested
|
|
return;
|
|
}
|
|
|
|
// click
|
|
options[0].click();
|
|
options[1].click();
|
|
expect(options[0].checked).to.equal(true);
|
|
expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
|
|
// also deselect
|
|
options[1].click();
|
|
expect(options[0].checked).to.equal(true);
|
|
expect(el.modelValue).to.eql(['Artichoke']);
|
|
|
|
// Reset
|
|
// @ts-ignore allow protected members in tests
|
|
el._uncheckChildren();
|
|
|
|
// Enter
|
|
el.activeIndex = 0;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
el.activeIndex = 1;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
expect(options[0].checked).to.equal(true);
|
|
expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
|
|
// also deselect
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
expect(options[0].checked).to.equal(true);
|
|
expect(el.modelValue).to.eql(['Artichoke']);
|
|
|
|
// @ts-ignore allow protected
|
|
if (el._listboxReceivesNoFocus) {
|
|
return; // if suite is run for combobox, we don't respond to [Space]
|
|
}
|
|
|
|
// Reset
|
|
// @ts-ignore allow protected members in tests
|
|
el._uncheckChildren();
|
|
|
|
// Space
|
|
el.activeIndex = 0;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
|
|
el.activeIndex = 1;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
|
|
expect(options[0].checked).to.equal(true);
|
|
expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
|
|
// also deselect
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
|
|
expect(options[0].checked).to.equal(true);
|
|
expect(el.modelValue).to.eql(['Artichoke']);
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('adds aria-multiselectable="true" to listbox node', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="foo" multiple-choice>
|
|
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { listbox } = getProtectedMembers(el);
|
|
expect(listbox.getAttribute('aria-multiselectable')).to.equal('true');
|
|
});
|
|
|
|
it('does not allow "selectionFollowsFocus"', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="foo" multiple-choice>
|
|
<${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { 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) {
|
|
expect(option.checked).to.be.true;
|
|
} else {
|
|
expect(option.checked).to.be.false;
|
|
}
|
|
});
|
|
}
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened selection-follows-focus orientation="horizontal" autocomplete="none">
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
|
|
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);
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(el.activeIndex).to.equal(1);
|
|
expect(el.checkedIndex).to.equal(1);
|
|
expectOnlyGivenOneOptionToBeChecked(options, 1);
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
|
|
expect(el.activeIndex).to.equal(0);
|
|
expect(el.checkedIndex).to.equal(0);
|
|
expectOnlyGivenOneOptionToBeChecked(options, 0);
|
|
});
|
|
it('navigates through list with [ArrowLeft] [ArrowRight] keys when horizontal: activates and checks the option', async () => {
|
|
/**
|
|
* @param {LionOption[]} options
|
|
* @param {number} selectedIndex
|
|
*/
|
|
function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) {
|
|
options.forEach((option, i) => {
|
|
if (i === selectedIndex) {
|
|
expect(option.checked).to.be.true;
|
|
} else {
|
|
expect(option.checked).to.be.false;
|
|
}
|
|
});
|
|
}
|
|
const el = /** @type {LionListbox} */ (await fixture(html`
|
|
<${tag} opened selection-follows-focus autocomplete="none">
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
|
</${tag}>
|
|
`));
|
|
|
|
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);
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
|
expect(el.activeIndex).to.equal(1);
|
|
expect(el.checkedIndex).to.equal(1);
|
|
expectOnlyGivenOneOptionToBeChecked(options, 1);
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
|
expect(el.activeIndex).to.equal(0);
|
|
expect(el.checkedIndex).to.equal(0);
|
|
expectOnlyGivenOneOptionToBeChecked(options, 0);
|
|
});
|
|
it('checks first and last option with [Home] and [End] keys', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} opened selection-follows-focus>
|
|
<${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'20'}>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${'30'} checked>Item 3</${optionTag}>
|
|
<${optionTag} .choiceValue=${'40'}>Item 4</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
// @ts-ignore allow protected
|
|
if (el._listboxReceivesNoFocus) {
|
|
return;
|
|
}
|
|
|
|
expect(el.modelValue).to.equal('30');
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
|
|
expect(el.modelValue).to.equal('10');
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
|
|
expect(el.modelValue).to.equal('40');
|
|
});
|
|
});
|
|
|
|
describe('Disabled Host', () => {
|
|
it('cannot be navigated with keyboard if disabled', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} disabled>
|
|
<${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
<${optionTag} checked .choiceValue=${'20'}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
await el.updateComplete;
|
|
const { checkedIndex } = el;
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(el.checkedIndex).to.equal(checkedIndex);
|
|
});
|
|
|
|
it('sync its disabled state to all options', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} opened>
|
|
<${optionTag} .choiceValue=${10} disabled>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const options = el.formElements;
|
|
el.disabled = true;
|
|
await el.updateComplete;
|
|
expect(options[0].disabled).to.be.true;
|
|
expect(options[1].disabled).to.be.true;
|
|
|
|
el.disabled = false;
|
|
await el.updateComplete;
|
|
expect(options[0].disabled).to.be.true;
|
|
expect(options[1].disabled).to.be.false;
|
|
});
|
|
|
|
it('can be enabled (incl. its options) even if it starts as disabled', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} disabled>
|
|
<${optionTag} .choiceValue=${10} disabled>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const options = el.formElements;
|
|
expect(options[0].disabled).to.be.true;
|
|
expect(options[1].disabled).to.be.true;
|
|
|
|
el.disabled = false;
|
|
await el.updateComplete;
|
|
expect(options[0].disabled).to.be.true;
|
|
expect(options[1].disabled).to.be.false;
|
|
});
|
|
});
|
|
|
|
describe('Disabled Options', () => {
|
|
it('does not skip disabled options but prevents checking them', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} opened autocomplete="inline" .selectionFollowsFocus="${false}">
|
|
<${optionTag} .choiceValue=${'Item 1'} checked>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${'Item 2'} disabled>Item 2</${optionTag}>
|
|
<${optionTag} .choiceValue=${'Item 3'}>Item 3</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
// Normalize activeIndex across multiple implementers of ListboxMixinSuite
|
|
el.activeIndex = 0;
|
|
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(el.activeIndex).to.equal(1);
|
|
|
|
expect(el.checkedIndex).to.equal(0);
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
// Checked index stays where it was
|
|
expect(el.checkedIndex).to.equal(0);
|
|
});
|
|
});
|
|
|
|
describe('Programmatic interaction', () => {
|
|
it('can set active state', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20} id="myId">Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { activeDescendantOwner } = getProtectedMembers(el);
|
|
|
|
const opt = el.formElements[1];
|
|
opt.active = true;
|
|
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('myId');
|
|
});
|
|
|
|
it('can set checked state', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const option = el.formElements[1];
|
|
option.checked = true;
|
|
expect(el.modelValue).to.equal(20);
|
|
});
|
|
|
|
it('does not allow to set checkedIndex or activeIndex to be out of bound', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} has-no-default-selected autocomplete="list">
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(() => {
|
|
el.activeIndex = -1;
|
|
el.activeIndex = 1;
|
|
el.checkedIndex = -1;
|
|
el.checkedIndex = 1;
|
|
}).to.not.throw();
|
|
expect(el.checkedIndex).to.equal(-1);
|
|
expect(el.activeIndex).to.equal(-1);
|
|
});
|
|
|
|
it('unsets checked on other options when option becomes checked', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const options = el.formElements;
|
|
options[0].checked = true;
|
|
expect(options[0].checked).to.be.true;
|
|
expect(options[1].checked).to.be.false;
|
|
options[1].checked = true;
|
|
expect(options[0].checked).to.be.false;
|
|
expect(options[1].checked).to.be.true;
|
|
});
|
|
|
|
it('unsets active on other options when option becomes active', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} active .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const options = el.formElements;
|
|
expect(options[0].active).to.be.true;
|
|
options[1].active = true;
|
|
expect(options[0].active).to.be.false;
|
|
});
|
|
});
|
|
|
|
// TODO: ChoiceGroup suite?
|
|
describe('Interaction states', () => {
|
|
it('becomes dirty if value changed once', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
expect(el.dirty).to.be.false;
|
|
el.modelValue = 20;
|
|
expect(el.dirty).to.be.true;
|
|
});
|
|
|
|
it('is prefilled if there is a value on init', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} checked .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(el.prefilled).to.be.true;
|
|
|
|
const elEmpty = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag} .choiceValue=${null}>Please select a value</${optionTag}>
|
|
<${optionTag} .choiceValue=${'10'}>Item 1</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
expect(elEmpty.prefilled).to.be.false;
|
|
});
|
|
});
|
|
|
|
// TODO: ChoiceGroup suite?
|
|
describe('Validation', () => {
|
|
it('can be required', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} .validators=${[new Required()]}>
|
|
<${optionTag} .choiceValue=${null}>Please select a value</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Complex Data', () => {
|
|
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`
|
|
<${tag} label="Favorite color" name="color">
|
|
${objs.map(
|
|
obj => html`
|
|
<${optionTag} .modelValue=${{ value: obj, checked: false }}
|
|
>${obj.label}</${optionTag}
|
|
>
|
|
`,
|
|
)}
|
|
</${tag}>
|
|
`);
|
|
el.checkedIndex = 0;
|
|
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,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Instantiation methods', () => {
|
|
it('can be instantiated via "document.createElement"', async () => {
|
|
let properlyInstantiated = false;
|
|
|
|
try {
|
|
const el = document.createElement(cfg.tagString);
|
|
const optionsEl = document.createElement('lion-options');
|
|
optionsEl.slot = 'input';
|
|
const optionEl = document.createElement(cfg.optionTagString);
|
|
optionsEl.appendChild(optionEl);
|
|
el.appendChild(optionsEl);
|
|
properlyInstantiated = true;
|
|
} catch (e) {
|
|
throw Error(e);
|
|
}
|
|
|
|
expect(properlyInstantiated).to.be.true;
|
|
});
|
|
|
|
it('can be instantiated without options', async () => {
|
|
const el = await fixture(html`
|
|
<${tag} name="foo">
|
|
<${optionTag} .choiceValue=${10} checked>Item 1</${optionTag}>
|
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
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(listbox.children.length).to.equal(2);
|
|
expect(listbox.children[0].tagName).to.equal(cfg.optionTagString.toUpperCase());
|
|
});
|
|
});
|
|
|
|
describe('Href Options', () => {
|
|
it('allows anchors to be clicked when a [href] attribute is present', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag}>Google</${optionTag}>
|
|
<${optionTag} .href=${'https://duckduckgo.com'}>DuckDuck Go</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
el.activeIndex = 1;
|
|
|
|
// Allow options that behave like anchors (think of Google Search) to trigger the anchor behavior
|
|
const activeOption = el.formElements[1];
|
|
const clickSpy = sinon.spy(activeOption, 'click');
|
|
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
|
|
expect(clickSpy).to.have.been.calledOnce;
|
|
});
|
|
|
|
it('does not allow anchors to be clicked when a [href] attribute is not present', async () => {
|
|
const el = await fixture(html`
|
|
<${tag}>
|
|
<${optionTag}>Google</${optionTag}>
|
|
<${optionTag} .href=${'https://duckduckgo.com'}>DuckDuck Go</${optionTag}>
|
|
</${tag}>
|
|
`);
|
|
|
|
const { listbox } = getProtectedMembers(el);
|
|
|
|
el.activeIndex = 0;
|
|
|
|
const activeOption = el.formElements[0];
|
|
const clickSpy = sinon.spy(activeOption, 'click');
|
|
|
|
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
|
|
expect(clickSpy).to.not.have.been.called;
|
|
});
|
|
});
|
|
});
|
|
}
|