@@ -820,6 +813,20 @@ const FormControlMixinImplementation = superclass =>
new CustomEvent('model-value-changed', { bubbles: true, detail: { formPath } }),
);
}
+
+ /**
+ * @overridable
+ * A Subclasser should only override this method if the interactive element
+ * ([slot=input]) is not a native element(like input, textarea, select)
+ * that already receives focus on label click.
+ *
+ * @example
+ * _onLabelClick() {
+ * this._invokerNode.focus();
+ * }
+ */
+ // eslint-disable-next-line class-methods-use-this
+ _onLabelClick() {}
};
export const FormControlMixin = dedupeMixin(FormControlMixinImplementation);
diff --git a/packages/form-core/src/choice-group/ChoiceGroupMixin.js b/packages/form-core/src/choice-group/ChoiceGroupMixin.js
index 597aba16f..f26b1f424 100644
--- a/packages/form-core/src/choice-group/ChoiceGroupMixin.js
+++ b/packages/form-core/src/choice-group/ChoiceGroupMixin.js
@@ -253,7 +253,7 @@ const ChoiceGroupMixinImplementation = superclass =>
}
_getCheckedElements() {
- // We want to filter out disabled values out by default
+ // We want to filter out disabled values by default
return this.formElements.filter(el => el.checked && !el.disabled);
}
diff --git a/packages/form-core/test-suites/FormatMixin.suite.js b/packages/form-core/test-suites/FormatMixin.suite.js
index 58ff36f95..fda8f011b 100644
--- a/packages/form-core/test-suites/FormatMixin.suite.js
+++ b/packages/form-core/test-suites/FormatMixin.suite.js
@@ -47,8 +47,7 @@ function mimicUserInput(formControl, newViewValue) {
export function runFormatMixinSuite(customConfig) {
const cfg = {
tagString: null,
- modelValueType: String,
- suffix: '',
+ childTagString: null,
...customConfig,
};
diff --git a/packages/form-core/test-suites/choice-group/ChoiceGroupMixin.suite.js b/packages/form-core/test-suites/choice-group/ChoiceGroupMixin.suite.js
new file mode 100644
index 000000000..60464c0e8
--- /dev/null
+++ b/packages/form-core/test-suites/choice-group/ChoiceGroupMixin.suite.js
@@ -0,0 +1,393 @@
+import { LitElement } from '@lion/core';
+import { LionInput } from '@lion/input';
+import '@lion/fieldset/lion-fieldset.js';
+import { FormGroupMixin, Required } from '@lion/form-core';
+import { expect, html, fixture, unsafeStatic } from '@open-wc/testing';
+import { ChoiceGroupMixin } from '../../src/choice-group/ChoiceGroupMixin.js';
+import { ChoiceInputMixin } from '../../src/choice-group/ChoiceInputMixin.js';
+
+class ChoiceInput extends ChoiceInputMixin(LionInput) {}
+customElements.define('choice-group-input', ChoiceInput);
+// @ts-expect-error
+class ChoiceGroup extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {}
+customElements.define('choice-group', ChoiceGroup);
+
+/**
+ * @param {{ parentTagString?:string, childTagString?: string}} [config]
+ */
+export function runChoiceGroupMixinSuite({ parentTagString, childTagString } = {}) {
+ const cfg = {
+ parentTagString: parentTagString || 'choice-group',
+ childTagString: childTagString || 'choice-group-input',
+ };
+
+ const parentTag = unsafeStatic(cfg.parentTagString);
+ const childTag = unsafeStatic(cfg.childTagString);
+
+ describe('ChoiceGroupMixin', () => {
+ it('has a single modelValue representing the currently checked radio value', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+ expect(el.modelValue).to.equal('female');
+ el.formElements[0].checked = true;
+ expect(el.modelValue).to.equal('male');
+ el.formElements[2].checked = true;
+ expect(el.modelValue).to.equal('other');
+ });
+
+ it('has a single formattedValue representing the currently checked radio value', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+ expect(el.formattedValue).to.equal('female');
+ el.formElements[0].checked = true;
+ expect(el.formattedValue).to.equal('male');
+ el.formElements[2].checked = true;
+ expect(el.formattedValue).to.equal('other');
+ });
+
+ it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+ const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${childTag} .modelValue=${'Lara'}>${childTag}>
+ `));
+
+ expect(() => {
+ el.addFormElement(invalidChild);
+ }).to.throw(
+ 'The choice-group name="gender" does not allow to register choice-group-input with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }',
+ );
+ });
+
+ it('automatically sets the name property of child radios to its own name', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender">
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.formElements[0].name).to.equal('gender');
+ expect(el.formElements[1].name).to.equal('gender');
+
+ const validChild = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ `));
+ el.appendChild(validChild);
+
+ expect(el.formElements[2].name).to.equal('gender');
+ });
+
+ it('throws if a child element with a different name than the group tries to register', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender">
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${childTag} name="foo" .choiceValue=${'male'}>${childTag}>
+ `));
+
+ expect(() => {
+ el.addFormElement(invalidChild);
+ }).to.throw(
+ 'The choice-group name="gender" does not allow to register choice-group-input with custom names (name="foo" given)',
+ );
+ });
+
+ it('can set initial modelValue on creation', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender" .modelValue=${'other'}>
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'}>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.modelValue).to.equal('other');
+ expect(el.formElements[2].checked).to.be.true;
+ });
+
+ it('can set initial serializedValue on creation', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender" .serializedValue=${'other'}>
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'}>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.serializedValue).to.equal('other');
+ expect(el.formElements[2].checked).to.be.true;
+ });
+
+ it('can set initial formattedValue on creation', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender" .formattedValue=${'other'}>
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'}>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.formattedValue).to.equal('other');
+ expect(el.formElements[2].checked).to.be.true;
+ });
+
+ it('can handle complex data via choiceValue', async () => {
+ const date = new Date(2018, 11, 24, 10, 33, 30, 0);
+
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="data">
+ <${childTag} .choiceValue=${{ some: 'data' }}>${childTag}>
+ <${childTag} .choiceValue=${date} checked>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.modelValue).to.equal(date);
+ el.formElements[0].checked = true;
+ expect(el.modelValue).to.deep.equal({ some: 'data' });
+ });
+
+ it('can handle 0 and empty string as valid values', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="data">
+ <${childTag} .choiceValue=${0} checked>${childTag}>
+ <${childTag} .choiceValue=${''}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.modelValue).to.equal(0);
+ el.formElements[1].checked = true;
+ expect(el.modelValue).to.equal('');
+ });
+
+ it('can check a radio by supplying an available modelValue', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender">
+ <${childTag}
+ .modelValue="${{ value: 'male', checked: false }}"
+ >${childTag}>
+ <${childTag}
+ .modelValue="${{ value: 'female', checked: true }}"
+ >${childTag}>
+ <${childTag}
+ .modelValue="${{ value: 'other', checked: false }}"
+ >${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.modelValue).to.equal('female');
+ el.modelValue = 'other';
+ expect(el.formElements[2].checked).to.be.true;
+ });
+
+ it('expect child nodes to only fire one model-value-changed event per instance', async () => {
+ let counter = 0;
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag}
+ name="gender"
+ @model-value-changed=${() => {
+ counter += 1;
+ }}
+ >
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag}
+ .modelValue=${{ value: 'female', checked: true }}
+ >${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ counter = 0; // reset after setup which may result in different results
+
+ el.formElements[0].checked = true;
+ expect(counter).to.equal(1); // male becomes checked, female becomes unchecked
+
+ // not changed values trigger no event
+ el.formElements[0].checked = true;
+ expect(counter).to.equal(1);
+
+ el.formElements[2].checked = true;
+ expect(counter).to.equal(2); // other becomes checked, male becomes unchecked
+
+ // not found values trigger no event
+ el.modelValue = 'foo';
+ expect(counter).to.equal(2);
+
+ el.modelValue = 'male';
+ expect(counter).to.equal(3); // male becomes checked, other becomes unchecked
+ });
+
+ it('can be required', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender" .validators=${[new Required()]}>
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag}
+ .choiceValue=${{ subObject: 'satisfies required' }}
+ >${childTag}>
+ ${parentTag}>
+ `));
+ expect(el.hasFeedbackFor).to.include('error');
+ expect(el.validationStates.error).to.exist;
+ expect(el.validationStates.error.Required).to.exist;
+
+ el.formElements[0].checked = true;
+ expect(el.hasFeedbackFor).not.to.include('error');
+ expect(el.validationStates.error).to.exist;
+ expect(el.validationStates.error.Required).to.not.exist;
+
+ el.formElements[1].checked = true;
+ expect(el.hasFeedbackFor).not.to.include('error');
+ expect(el.validationStates.error).to.exist;
+ expect(el.validationStates.error.Required).to.not.exist;
+ });
+
+ it('returns serialized value', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'}>${childTag}>
+ ${parentTag}>
+ `));
+ el.formElements[0].checked = true;
+ expect(el.serializedValue).to.deep.equal('male');
+ });
+
+ it('returns serialized value on unchecked state', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} name="gender">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.serializedValue).to.deep.equal('');
+ });
+
+ describe('multipleChoice', () => {
+ it('has a single modelValue representing all currently checked values', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} multiple-choice name="gender[]">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.modelValue).to.eql(['female']);
+ el.formElements[0].checked = true;
+ expect(el.modelValue).to.eql(['male', 'female']);
+ el.formElements[2].checked = true;
+ expect(el.modelValue).to.eql(['male', 'female', 'other']);
+ });
+
+ it('has a single serializedValue representing all currently checked values', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} multiple-choice name="gender[]">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.serializedValue).to.eql(['female']);
+ el.formElements[0].checked = true;
+ expect(el.serializedValue).to.eql(['male', 'female']);
+ el.formElements[2].checked = true;
+ expect(el.serializedValue).to.eql(['male', 'female', 'other']);
+ });
+
+ it('has a single formattedValue representing all currently checked values', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} multiple-choice name="gender[]">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.formattedValue).to.eql(['female']);
+ el.formElements[0].checked = true;
+ expect(el.formattedValue).to.eql(['male', 'female']);
+ el.formElements[2].checked = true;
+ expect(el.formattedValue).to.eql(['male', 'female', 'other']);
+ });
+
+ it('can check multiple checkboxes by setting the modelValue', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} multiple-choice name="gender[]">
+ <${childTag} .choiceValue=${'male'}>${childTag}>
+ <${childTag} .choiceValue=${'female'}>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+ `));
+
+ el.modelValue = ['male', 'other'];
+ expect(el.modelValue).to.eql(['male', 'other']);
+ expect(el.formElements[0].checked).to.be.true;
+ expect(el.formElements[2].checked).to.be.true;
+ });
+
+ it('unchecks non-matching checkboxes when setting the modelValue', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+ <${parentTag} multiple-choice name="gender[]">
+ <${childTag} .choiceValue=${'male'} checked>${childTag}>
+ <${childTag} .choiceValue=${'female'}>${childTag}>
+ <${childTag} .choiceValue=${'other'} checked>${childTag}>
+ ${parentTag}>
+ `));
+
+ expect(el.modelValue).to.eql(['male', 'other']);
+ expect(el.formElements[0].checked).to.be.true;
+ expect(el.formElements[2].checked).to.be.true;
+
+ el.modelValue = ['female'];
+ expect(el.formElements[0].checked).to.be.false;
+ expect(el.formElements[1].checked).to.be.true;
+ expect(el.formElements[2].checked).to.be.false;
+ });
+ });
+
+ describe('Integration with a parent form/fieldset', () => {
+ it('will serialize all children with their serializedValue', async () => {
+ const el = /** @type {ChoiceGroup} */ (await fixture(html`
+
+ <${parentTag} name="gender">
+ <${childTag} .choiceValue=${'male'} checked disabled>${childTag}>
+ <${childTag} .choiceValue=${'female'} checked>${childTag}>
+ <${childTag} .choiceValue=${'other'}>${childTag}>
+ ${parentTag}>
+
+ `));
+
+ expect(el.serializedValue).to.eql({
+ gender: 'female',
+ });
+
+ const choiceGroupEl = /** @type {ChoiceGroup} */ (el.querySelector('[name="gender"]'));
+ choiceGroupEl.multipleChoice = true;
+ expect(el.serializedValue).to.eql({
+ gender: ['female'],
+ });
+ });
+ });
+ });
+}
diff --git a/packages/form-core/test/FormControlMixin.test.js b/packages/form-core/test/FormControlMixin.test.js
index 190bdc7fb..b2c36ae8e 100644
--- a/packages/form-core/test/FormControlMixin.test.js
+++ b/packages/form-core/test/FormControlMixin.test.js
@@ -104,22 +104,22 @@ describe('FormControlMixin', () => {
});
it('does not duplicate aria-describedby and aria-labelledby ids', async () => {
- const lionField = /** @type {FormControlMixinClass} */ (await fixture(`
+ const el = /** @type {FormControlMixinClass} */ (await fixture(`
<${tagString} help-text="This element will be disconnected/reconnected">${inputSlot}${tagString}>
`));
const wrapper = /** @type {LitElement} */ (await fixture(`
`));
- lionField.parentElement?.appendChild(wrapper);
- wrapper.appendChild(lionField);
+ el.parentElement?.appendChild(wrapper);
+ wrapper.appendChild(el);
await wrapper.updateComplete;
['aria-describedby', 'aria-labelledby'].forEach(ariaAttributeName => {
- const ariaAttribute = Array.from(lionField.children)
+ const ariaAttribute = Array.from(el.children)
.find(child => child.slot === 'input')
?.getAttribute(ariaAttributeName)
?.trim()
.split(' ');
- const hasDuplicate = !!ariaAttribute?.find((el, i) => ariaAttribute.indexOf(el) !== i);
+ const hasDuplicate = !!ariaAttribute?.find((elm, i) => ariaAttribute.indexOf(elm) !== i);
expect(hasDuplicate).to.be.false;
});
});
@@ -186,20 +186,32 @@ describe('FormControlMixin', () => {
});
it('adds aria-live="polite" to the feedback slot', async () => {
- const lionField = await fixture(html`
+ const el = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}>
${inputSlot}
Added to see attributes
${tag}>
- `);
+ `));
expect(
- Array.from(lionField.children)
+ Array.from(el.children)
.find(child => child.slot === 'feedback')
?.getAttribute('aria-live'),
).to.equal('polite');
});
+ it('clicking the label should call `_onLabelClick`', async () => {
+ const spy = sinon.spy();
+ const el = /** @type {FormControlMixinClass} */ (await fixture(html`
+ <${tag} ._onLabelClick="${spy}">
+ ${inputSlot}
+ ${tag}>
+ `));
+ expect(spy).to.not.have.been.called;
+ el._labelNode.click();
+ expect(spy).to.have.been.calledOnce;
+ });
+
describe('Model-value-changed event propagation', () => {
// @ts-expect-error base constructor same return type
const FormControlWithRegistrarMixinClass = class extends FormControlMixin(
diff --git a/packages/form-core/test/choice-group/ChoiceGroupMixin.test.js b/packages/form-core/test/choice-group/ChoiceGroupMixin.test.js
index 95fd8eb55..595e8d504 100644
--- a/packages/form-core/test/choice-group/ChoiceGroupMixin.test.js
+++ b/packages/form-core/test/choice-group/ChoiceGroupMixin.test.js
@@ -1,394 +1,3 @@
-import { html, LitElement } from '@lion/core';
-import { LionInput } from '@lion/input';
-import '@lion/fieldset/lion-fieldset.js';
-import { FormGroupMixin, Required } from '@lion/form-core';
-import { expect, fixture } from '@open-wc/testing';
-import { ChoiceGroupMixin } from '../../src/choice-group/ChoiceGroupMixin.js';
-import { ChoiceInputMixin } from '../../src/choice-group/ChoiceInputMixin.js';
-// import { LionField } from '../../src/LionField.js';
+import { runChoiceGroupMixinSuite } from '../../test-suites/choice-group/ChoiceGroupMixin.suite.js';
-// class InputField extends LionField {
-// get slots() {
-// return {
-// ...super.slots,
-// input: () => document.createElement('input'),
-// };
-// }
-// }
-
-describe('ChoiceGroupMixin', () => {
- class ChoiceInput extends ChoiceInputMixin(LionInput) {}
- customElements.define('choice-group-input', ChoiceInput);
- // @ts-expect-error
- class ChoiceGroup extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {}
- customElements.define('choice-group', ChoiceGroup);
- // @ts-expect-error
- class ChoiceGroupMultiple extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {
- constructor() {
- super();
- this.multipleChoice = true;
- }
- }
- customElements.define('choice-group-multiple', ChoiceGroupMultiple);
-
- it('has a single modelValue representing the currently checked radio value', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
- expect(el.modelValue).to.equal('female');
- el.formElements[0].checked = true;
- expect(el.modelValue).to.equal('male');
- el.formElements[2].checked = true;
- expect(el.modelValue).to.equal('other');
- });
-
- it('has a single formattedValue representing the currently checked radio value', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
- expect(el.formattedValue).to.equal('female');
- el.formElements[0].checked = true;
- expect(el.formattedValue).to.equal('male');
- el.formElements[2].checked = true;
- expect(el.formattedValue).to.equal('other');
- });
-
- it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
- const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
-
- `));
-
- expect(() => {
- el.addFormElement(invalidChild);
- }).to.throw(
- 'The choice-group name="gender" does not allow to register choice-group-input with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }',
- );
- });
-
- it('automatically sets the name property of child radios to its own name', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
- `));
-
- expect(el.formElements[0].name).to.equal('gender');
- expect(el.formElements[1].name).to.equal('gender');
-
- const validChild = /** @type {ChoiceGroup} */ (await fixture(html`
-
- `));
- el.appendChild(validChild);
-
- expect(el.formElements[2].name).to.equal('gender');
- });
-
- it('throws if a child element with a different name than the group tries to register', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
- `));
-
- const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
-
- `));
-
- expect(() => {
- el.addFormElement(invalidChild);
- }).to.throw(
- 'The choice-group name="gender" does not allow to register choice-group-input with custom names (name="foo" given)',
- );
- });
-
- it('can set initial modelValue on creation', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- expect(el.modelValue).to.equal('other');
- expect(el.formElements[2].checked).to.be.true;
- });
-
- it('can set initial serializedValue on creation', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- expect(el.serializedValue).to.equal('other');
- expect(el.formElements[2].checked).to.be.true;
- });
-
- it('can set initial formattedValue on creation', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- expect(el.formattedValue).to.equal('other');
- expect(el.formElements[2].checked).to.be.true;
- });
-
- it('can handle complex data via choiceValue', async () => {
- const date = new Date(2018, 11, 24, 10, 33, 30, 0);
-
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
- `));
-
- expect(el.modelValue).to.equal(date);
- el.formElements[0].checked = true;
- expect(el.modelValue).to.deep.equal({ some: 'data' });
- });
-
- it('can handle 0 and empty string as valid values', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
- `));
-
- expect(el.modelValue).to.equal(0);
- el.formElements[1].checked = true;
- expect(el.modelValue).to.equal('');
- });
-
- it('can check a radio by supplying an available modelValue', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- expect(el.modelValue).to.equal('female');
- el.modelValue = 'other';
- expect(el.formElements[2].checked).to.be.true;
- });
-
- it('expect child nodes to only fire one model-value-changed event per instance', async () => {
- let counter = 0;
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
{
- counter += 1;
- }}
- >
-
-
-
-
- `));
-
- counter = 0; // reset after setup which may result in different results
-
- el.formElements[0].checked = true;
- expect(counter).to.equal(1); // male becomes checked, female becomes unchecked
-
- // not changed values trigger no event
- el.formElements[0].checked = true;
- expect(counter).to.equal(1);
-
- el.formElements[2].checked = true;
- expect(counter).to.equal(2); // other becomes checked, male becomes unchecked
-
- // not found values trigger no event
- el.modelValue = 'foo';
- expect(counter).to.equal(2);
-
- el.modelValue = 'male';
- expect(counter).to.equal(3); // male becomes checked, other becomes unchecked
- });
-
- it('can be required', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
- `));
- expect(el.hasFeedbackFor).to.include('error');
- expect(el.validationStates.error).to.exist;
- expect(el.validationStates.error.Required).to.exist;
-
- el.formElements[0].checked = true;
- expect(el.hasFeedbackFor).not.to.include('error');
- expect(el.validationStates.error).to.exist;
- expect(el.validationStates.error.Required).to.not.exist;
-
- el.formElements[1].checked = true;
- expect(el.hasFeedbackFor).not.to.include('error');
- expect(el.validationStates.error).to.exist;
- expect(el.validationStates.error.Required).to.not.exist;
- });
-
- it('returns serialized value', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
- `));
- el.formElements[0].checked = true;
- expect(el.serializedValue).to.deep.equal('male');
- });
-
- it('returns serialized value on unchecked state', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
- `));
-
- expect(el.serializedValue).to.deep.equal('');
- });
-
- describe('multipleChoice', () => {
- it('has a single modelValue representing all currently checked values', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- expect(el.modelValue).to.eql(['female']);
- el.formElements[0].checked = true;
- expect(el.modelValue).to.eql(['male', 'female']);
- el.formElements[2].checked = true;
- expect(el.modelValue).to.eql(['male', 'female', 'other']);
- });
-
- it('has a single serializedValue representing all currently checked values', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- expect(el.serializedValue).to.eql(['female']);
- el.formElements[0].checked = true;
- expect(el.serializedValue).to.eql(['male', 'female']);
- el.formElements[2].checked = true;
- expect(el.serializedValue).to.eql(['male', 'female', 'other']);
- });
-
- it('has a single formattedValue representing all currently checked values', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- expect(el.formattedValue).to.eql(['female']);
- el.formElements[0].checked = true;
- expect(el.formattedValue).to.eql(['male', 'female']);
- el.formElements[2].checked = true;
- expect(el.formattedValue).to.eql(['male', 'female', 'other']);
- });
-
- it('can check multiple checkboxes by setting the modelValue', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- el.modelValue = ['male', 'other'];
- expect(el.modelValue).to.eql(['male', 'other']);
- expect(el.formElements[0].checked).to.be.true;
- expect(el.formElements[2].checked).to.be.true;
- });
-
- it('unchecks non-matching checkboxes when setting the modelValue', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
- `));
-
- expect(el.modelValue).to.eql(['male', 'other']);
- expect(el.formElements[0].checked).to.be.true;
- expect(el.formElements[2].checked).to.be.true;
-
- el.modelValue = ['female'];
- expect(el.formElements[0].checked).to.be.false;
- expect(el.formElements[1].checked).to.be.true;
- expect(el.formElements[2].checked).to.be.false;
- });
- });
-
- describe('Integration with a parent form/fieldset', () => {
- it('will serialize all children with their serializedValue', async () => {
- const el = /** @type {ChoiceGroup} */ (await fixture(html`
-
-
-
-
-
-
-
- `));
-
- expect(el.serializedValue).to.eql({
- gender: 'female',
- });
-
- const choiceGroupEl = /** @type {ChoiceGroup} */ (el.querySelector('[name="gender"]'));
- choiceGroupEl.multipleChoice = true;
- expect(el.serializedValue).to.eql({
- gender: ['female'],
- });
- });
- });
-});
+runChoiceGroupMixinSuite();
diff --git a/packages/form-core/types/FormControlMixinTypes.d.ts b/packages/form-core/types/FormControlMixinTypes.d.ts
index 0c2bd205f..f3214e3ac 100644
--- a/packages/form-core/types/FormControlMixinTypes.d.ts
+++ b/packages/form-core/types/FormControlMixinTypes.d.ts
@@ -96,8 +96,6 @@ export class FormControlHost {
_enhanceLightDomA11y(): void;
_enhanceLightDomA11yForAdditionalSlots(additionalSlots?: string[]): void;
__reflectAriaAttr(attrName: string, nodes: HTMLElement[], reorder: boolean | undefined): void;
- _onLabelChanged({ label }: { label: string }): void;
- _onHelpTextChanged({ helpText }: { helpText: string }): void;
_isEmpty(modelValue?: unknown): boolean;
_getAriaDescriptionElements(): HTMLElement[];
addToAriaLabelledBy(
diff --git a/packages/form-integrations/docs/15-features-overview.md b/packages/form-integrations/docs/15-features-overview.md
index 327cc1372..7f03d30ef 100644
--- a/packages/form-integrations/docs/15-features-overview.md
+++ b/packages/form-integrations/docs/15-features-overview.md
@@ -22,8 +22,8 @@ import '@lion/input/lion-input.js';
import '@lion/radio-group/lion-radio-group.js';
import '@lion/radio-group/lion-radio.js';
import '@lion/select/lion-select.js';
-import '@lion/select-rich/lion-option.js';
-import '@lion/select-rich/lion-options.js';
+import '@lion/listbox/lion-option.js';
+import '@lion/listbox/lion-options.js';
import '@lion/select-rich/lion-select-rich.js';
import '@lion/textarea/lion-textarea.js';
import { MinLength, Required } from '@lion/form-core';
@@ -126,9 +126,7 @@ export const main = () => {
RSVP
-
- Max. 5 guests
-
+ Max. 5 guests
diff --git a/packages/form-integrations/docs/60-dialog-integration.md b/packages/form-integrations/docs/60-dialog-integration.md
index 356256b1f..cbbf058b7 100644
--- a/packages/form-integrations/docs/60-dialog-integration.md
+++ b/packages/form-integrations/docs/60-dialog-integration.md
@@ -6,8 +6,8 @@
import { html } from 'lit-html';
import '@lion/dialog/lion-dialog.js';
import '@lion/select-rich/lion-select-rich.js';
-import '@lion/select-rich/lion-options.js';
-import '@lion/select-rich/lion-option.js';
+import '@lion/listbox/lion-options.js';
+import '@lion/listbox/lion-option.js';
export default {
title: 'Forms/System/Dialog integrations',
diff --git a/packages/listbox/README.md b/packages/listbox/README.md
index 2252e1c7c..b1dbe0d8f 100644
--- a/packages/listbox/README.md
+++ b/packages/listbox/README.md
@@ -23,7 +23,7 @@ export default {
export const main = () => html`
Apple
- Artichoke
+ Artichoke
Asparagus
Banana
Beets
@@ -77,7 +77,7 @@ export const multiple = () => html`
When `orientation="horizontal"`, left and right arrow keys will be enabled, plus the screenreader
will be informed about the direction of the options.
-By default, `orientation="horizontal"` is set, which enables up and down arrow keys.
+By default, `orientation="vertical"` is set, which enables up and down arrow keys.
```js preview-story
export const orientationHorizontal = () => html`
@@ -146,7 +146,7 @@ export const selectionFollowsFocus = () => html`
## Rotate keyboard navigation
-Will give first option active state when navigated to the next option from last option.
+`rotate-keyboard-navigation` attribute on the listbox will give the first option active state when navigated to the next option from last option.
```js preview-story
export const rotateKeyboardNavigation = () => html`
@@ -164,3 +164,24 @@ export const rotateKeyboardNavigation = () => html`
`;
```
+
+## Disabled options
+
+Navigation will skip over disabled options. Let's disable Artichoke and Brussel sprout, because they're gross.
+
+```js preview-story
+export const disabledRotateNavigation = () => html`
+
+ Apple
+ Artichoke
+ Asparagus
+ Banana
+ Beets
+ Bell pepper
+ Broccoli
+ Brussels sprout
+ Cabbage
+ Carrot
+
+`;
+```
diff --git a/packages/listbox/src/LionOption.js b/packages/listbox/src/LionOption.js
index 11d4f3ec6..02b0e2c8e 100644
--- a/packages/listbox/src/LionOption.js
+++ b/packages/listbox/src/LionOption.js
@@ -78,7 +78,7 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
requestUpdateInternal(name, oldValue) {
super.requestUpdateInternal(name, oldValue);
- if (name === 'active') {
+ if (name === 'active' && this.active !== oldValue) {
this.dispatchEvent(new Event('active-changed', { bubbles: true }));
}
}
diff --git a/packages/listbox/src/LionOptions.js b/packages/listbox/src/LionOptions.js
index 120967268..7ee1f3157 100644
--- a/packages/listbox/src/LionOptions.js
+++ b/packages/listbox/src/LionOptions.js
@@ -22,8 +22,6 @@ export class LionOptions extends FormRegistrarPortalMixin(LitElement) {
constructor() {
super();
this.role = 'listbox';
- // we made it a Lit-Element property because of this
- // eslint-disable-next-line wc/no-constructor-attributes
this.tabIndex = 0;
}
diff --git a/packages/listbox/src/ListboxMixin.js b/packages/listbox/src/ListboxMixin.js
index 6035eccc2..e969e094e 100644
--- a/packages/listbox/src/ListboxMixin.js
+++ b/packages/listbox/src/ListboxMixin.js
@@ -1,6 +1,7 @@
import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/form-core';
import { css, html, dedupeMixin, ScopedElementsMixin, SlotMixin } from '@lion/core';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
+import '@lion/core/src/closestPolyfill.js';
import { LionOptions } from './LionOptions.js';
// TODO: extract ListNavigationWithActiveDescendantMixin that can be reused in [role="menu"]
@@ -92,6 +93,19 @@ const ListboxMixinImplementation = superclass =>
];
}
+ /**
+ * @override FormControlMixin
+ */
+ // eslint-disable-next-line
+ _inputGroupInputTemplate() {
+ return html`
+
+
+
+
+ `;
+ }
+
static get scopedElements() {
return {
...super.scopedElements,
@@ -99,6 +113,7 @@ const ListboxMixinImplementation = superclass =>
};
}
+ // @ts-ignore
get slots() {
return {
...super.slots,
@@ -112,24 +127,86 @@ const ListboxMixinImplementation = superclass =>
};
}
+ /**
+ * @configure FormControlMixin
+ */
+ get _inputNode() {
+ return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
+ }
+
+ /**
+ * @overridable
+ * @type {LionOptions}
+ */
get _listboxNode() {
return /** @type {LionOptions} */ (this._inputNode);
}
+ /**
+ * @overridable
+ * @type {HTMLElement}
+ */
get _listboxActiveDescendantNode() {
- return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`);
+ return /** @type {HTMLElement} */ (this._listboxNode.querySelector(
+ `#${this._listboxActiveDescendant}`,
+ ));
}
+ /**
+ * @overridable
+ * @type {HTMLElement}
+ */
+ get _listboxSlot() {
+ return /** @type {HTMLElement} */ (
+ /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('slot[name=input]')
+ );
+ }
+
+ /**
+ * @overridable
+ * @type {HTMLElement}
+ */
+ get _scrollTargetNode() {
+ return this._listboxNode;
+ }
+
+ /**
+ * @overridable
+ * @type {HTMLElement}
+ */
+ get _activeDescendantOwnerNode() {
+ return this._listboxNode;
+ }
+
+ /**
+ * @override ChoiceGroupMixin
+ */
get serializedValue() {
return this.modelValue;
}
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
// If you override one, gotta override the other, they go in pairs.
+ /**
+ * @override ChoiceGroupMixin
+ */
set serializedValue(value) {
super.serializedValue = value;
}
+ get activeIndex() {
+ return this.formElements.findIndex(el => el.active === true);
+ }
+
+ set activeIndex(index) {
+ if (this.formElements[index]) {
+ const el = this.formElements[index];
+ this.__setChildActive(el);
+ } else {
+ this.__setChildActive(null);
+ }
+ }
+
/**
* @type {number | number[]}
*/
@@ -151,47 +228,11 @@ const ListboxMixinImplementation = superclass =>
this.setCheckedIndex(index);
}
- /**
- * When `multipleChoice` is false, will toggle, else will check provided index
- * @param {Number} index
- */
- setCheckedIndex(index) {
- if (this.formElements[index]) {
- if (!this.multipleChoice) {
- this.formElements[index].checked = true;
- } else {
- this.formElements[index].checked = !this.formElements[index].checked;
- // __onChildCheckedChanged, which also responds to programmatic (model)value changes
- // of children, will do the rest
- }
- }
- }
-
- get activeIndex() {
- return this.formElements.findIndex(el => el.active === true);
- }
-
- get _scrollTargetNode() {
- return this._listboxNode;
- }
-
- set activeIndex(index) {
- if (this.formElements[index]) {
- const el = this.formElements[index];
- el.active = true;
-
- if (!isInView(this._scrollTargetNode, el)) {
- el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- }
- }
-
constructor() {
super();
- // this.disabled = false;
/**
* When setting this to true, on initial render, no option will be selected.
- * It it advisable to override `_noSelectionTemplate` method in the select-invoker
+ * It is advisable to override `_noSelectionTemplate` method in the select-invoker
* to render some kind of placeholder initially
*/
this.hasNoDefaultSelected = false;
@@ -219,6 +260,12 @@ const ListboxMixinImplementation = superclass =>
this.__hasInitialSelectedFormElement = false;
this._repropagationRole = 'choice-group'; // configures FormControlMixin
+ /**
+ * When listbox is coupled to a textbox (in case we are dealing with a combobox),
+ * spaces should not select an element (they need to be put in the textbox)
+ */
+ this._listboxReceivesNoFocus = false;
+
/** @type {EventListener} */
this._listboxOnKeyDown = this._listboxOnKeyDown.bind(this);
/** @type {EventListener} */
@@ -229,17 +276,20 @@ const ListboxMixinImplementation = superclass =>
this._onChildActiveChanged = this._onChildActiveChanged.bind(this);
/** @type {EventListener} */
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
+ /** @type {EventListener} */
+ this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
}
connectedCallback() {
if (this._listboxNode) {
- // if there is none yet, it will be supplied via static get slots
+ // if there is none yet, it will be supplied via 'get slots'
this._listboxNode.registrationTarget = this;
}
super.connectedCallback();
- this.__setupListboxNode();
+ this._setupListboxNode();
this.__setupEventListeners();
+ // TODO: should this be handled at a more generic level?
this.registrationComplete.then(() => {
this.__initInteractionStates();
});
@@ -250,23 +300,25 @@ const ListboxMixinImplementation = superclass =>
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
-
this.__moveOptionsToListboxNode();
}
/**
- * Moves options put in regulat slot to slot wiht role=listbox
+ * @param {import('lit-element').PropertyValues } changedProperties
*/
- __moveOptionsToListboxNode() {
- const slot = /** @type {HTMLSlotElement} */ (
- /** @type {ShadowRoot} */ (this.shadowRoot).getElementById('options-outlet')
- );
- if (slot) {
- slot.addEventListener('slotchange', () => {
- slot.assignedNodes().forEach(node => {
- this._listboxNode.appendChild(node);
- });
- });
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ if (this.formElements.length === 1) {
+ this.singleOption = true;
+ }
+
+ if (changedProperties.has('disabled')) {
+ if (this.disabled) {
+ this.__requestOptionsToBeDisabled();
+ } else {
+ this.__retractRequestOptionsToBeDisabled();
+ }
}
}
@@ -278,63 +330,28 @@ const ListboxMixinImplementation = superclass =>
}
/**
- * In the select disabled options are still going to a possible value for example
- * when prefilling or programmatically setting it.
- *
- * @override
+ * When `multipleChoice` is false, will toggle, else will check provided index
+ * @param {number} index
+ * @param {'set'|'unset'|'toggle'} multiMode
*/
- _getCheckedElements() {
- return this.formElements.filter(el => el.checked);
- }
-
- __initInteractionStates() {
- this.initInteractionState();
- }
-
- // TODO: inherit from FormControl ?
- get _inputNode() {
- return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
- }
-
- /**
- * @param {import('lit-element').PropertyValues } changedProperties
- */
- updated(changedProperties) {
- super.updated(changedProperties);
-
- if (this.formElements.length === 1) {
- this.singleOption = true;
- // this._invokerNode.singleOption = true;
- }
-
- if (changedProperties.has('disabled')) {
- if (this.disabled) {
- // this._invokerNode.makeRequestToBeDisabled();
- this.__requestOptionsToBeDisabled();
+ setCheckedIndex(index, multiMode = 'toggle') {
+ if (this.formElements[index]) {
+ if (!this.multipleChoice) {
+ this.formElements[index].checked = true;
+ // In __onChildCheckedChanged, which also responds to programmatic (model)value changes
+ // of children, we do the rest (uncheck siblings)
+ } else if (multiMode === 'toggle') {
+ this.formElements[index].checked = !this.formElements[index].checked;
} else {
- // this._invokerNode.retractRequestToBeDisabled();
- this.__retractRequestOptionsToBeDisabled();
+ this.formElements[index].checked = multiMode === 'set';
}
+ } else if (!this.multipleChoice) {
+ this._uncheckChildren();
}
}
/**
- * @override
- */
- // eslint-disable-next-line
- _inputGroupInputTemplate() {
- return html`
-
-
-
-
- `;
- }
-
- /**
- * Overrides FormRegistrar adding to make sure children have specific default states when added
- *
- * @override
+ * @enhance FormRegistrarMixin: make sure children have specific default states when added
* @param {LionOption} child
* @param {Number} indexToInsertAt
*/
@@ -342,7 +359,6 @@ const ListboxMixinImplementation = superclass =>
addFormElement(child, indexToInsertAt) {
// @ts-expect-error
super.addFormElement(/** @type {FormControl} */ child, indexToInsertAt);
-
// we need to adjust the elements being registered
/* eslint-disable no-param-reassign */
child.id = child.id || `${this.localName}-option-${uuid()}`;
@@ -351,17 +367,6 @@ const ListboxMixinImplementation = superclass =>
child.makeRequestToBeDisabled();
}
- // the first elements checked by default
- if (
- !this.hasNoDefaultSelected &&
- !this.__hasInitialSelectedFormElement &&
- (!child.disabled || this.disabled)
- ) {
- child.active = true;
- child.checked = true;
- this.__hasInitialSelectedFormElement = true;
- }
-
// TODO: small perf improvement could be made if logic below would be scheduled to next update,
// so it occurs once for all options
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
@@ -376,6 +381,183 @@ const ListboxMixinImplementation = superclass =>
/* eslint-enable no-param-reassign */
}
+ /**
+ * @override ChoiceGroupMixin: in the select disabled options are still going to a possible
+ * value, for example when prefilling or programmatically setting it.
+ */
+ _getCheckedElements() {
+ return this.formElements.filter(el => el.checked);
+ }
+
+ _setupListboxNode() {
+ if (this._listboxNode) {
+ this.__setupListboxNodeInteractions();
+ } else if (this._listboxSlot) {
+ /**
+ * For ShadyDom the listboxNode is available right from the start so we can add those events
+ * immediately.
+ * For native ShadowDom the select gets rendered before the listboxNode is available so we
+ * will add an event to the slotchange and add the events once available.
+ */
+ this._listboxSlot.addEventListener('slotchange', () => {
+ this.__setupListboxNodeInteractions();
+ });
+ }
+ }
+
+ _teardownListboxNode() {
+ if (this._listboxNode) {
+ this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
+ this._listboxNode.removeEventListener('click', this._listboxOnClick);
+ this._listboxNode.removeEventListener('keyup', this._listboxOnKeyUp);
+ }
+ }
+
+ /**
+ * @param {number} currentIndex
+ * @param {number} [offset=1]
+ */
+ _getNextEnabledOption(currentIndex, offset = 1) {
+ return this.__getEnabledOption(currentIndex, offset);
+ }
+
+ /**
+ * @param {number} currentIndex
+ * @param {number} [offset=-1]
+ */
+ _getPreviousEnabledOption(currentIndex, offset = -1) {
+ return this.__getEnabledOption(currentIndex, offset);
+ }
+
+ /**
+ * @overridable
+ * @param {Event & { target: LionOption }} ev
+ */
+ // eslint-disable-next-line no-unused-vars, class-methods-use-this
+ _onChildActiveChanged({ target }) {
+ if (target.active === true) {
+ this.__setChildActive(target);
+ }
+ }
+
+ /**
+ * @desc
+ * Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
+ * an item.
+ *
+ * @param {KeyboardEvent} ev - the keydown event object
+ */
+ _listboxOnKeyDown(ev) {
+ if (this.disabled) {
+ return;
+ }
+
+ const { key } = ev;
+
+ switch (key) {
+ case ' ':
+ case 'Enter': {
+ if (key === ' ' && this._listboxReceivesNoFocus) {
+ return;
+ }
+ ev.preventDefault();
+ if (!this.formElements[this.activeIndex]) {
+ return;
+ }
+
+ if (this.formElements[this.activeIndex].disabled) {
+ return;
+ }
+ this.setCheckedIndex(this.activeIndex);
+ break;
+ }
+ case 'ArrowUp':
+ ev.preventDefault();
+ if (this.orientation === 'vertical') {
+ this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
+ }
+ break;
+ case 'ArrowLeft':
+ if (this.orientation === 'horizontal') {
+ this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
+ }
+ break;
+ case 'ArrowDown':
+ ev.preventDefault();
+ if (this.orientation === 'vertical') {
+ this.activeIndex = this._getNextEnabledOption(this.activeIndex);
+ }
+ break;
+ case 'ArrowRight':
+ if (this.orientation === 'horizontal') {
+ this.activeIndex = this._getNextEnabledOption(this.activeIndex);
+ }
+ break;
+ case 'Home':
+ if (this._listboxReceivesNoFocus) {
+ return;
+ }
+ ev.preventDefault();
+ this.activeIndex = this._getNextEnabledOption(0, 0);
+ break;
+ case 'End':
+ if (this._listboxReceivesNoFocus) {
+ return;
+ }
+ ev.preventDefault();
+ this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0);
+ break;
+ /* no default */
+ }
+
+ const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End'];
+ if (keys.includes(key) && this.selectionFollowsFocus && !this.multipleChoice) {
+ this.setCheckedIndex(this.activeIndex);
+ }
+ }
+
+ /**
+ * @overridable
+ * @param {MouseEvent} ev
+ */
+ // eslint-disable-next-line class-methods-use-this, no-unused-vars
+ _listboxOnClick(ev) {
+ const option = /** @type {HTMLElement} */ (ev.target).closest('[role=option]');
+ const foundIndex = this.formElements.indexOf(option);
+ if (foundIndex > -1) {
+ this.activeIndex = foundIndex;
+ this.setCheckedIndex(foundIndex);
+ }
+ }
+
+ /**
+ * @overridable
+ * @param {KeyboardEvent} ev
+ */
+ // eslint-disable-next-line class-methods-use-this, no-unused-vars
+ _listboxOnKeyUp(ev) {
+ if (this.disabled) {
+ return;
+ }
+ const { key } = ev;
+ // eslint-disable-next-line default-case
+ switch (key) {
+ case 'ArrowUp':
+ case 'ArrowDown':
+ case 'Home':
+ case 'End':
+ case 'Enter':
+ ev.preventDefault();
+ }
+ }
+
+ /**
+ * @configure FormControlMixin
+ */
+ _onLabelClick() {
+ this._listboxNode.focus();
+ }
+
__setupEventListeners() {
this._listboxNode.addEventListener(
'active-changed',
@@ -385,8 +567,6 @@ const ListboxMixinImplementation = superclass =>
'model-value-changed',
/** @type {EventListener} */ (this.__proxyChildModelValueChanged),
);
-
- // this._listboxNode.addEventListener('checked-changed', this.__onChildCheckedChanged);
}
__teardownEventListeners() {
@@ -401,18 +581,34 @@ const ListboxMixinImplementation = superclass =>
}
/**
- * @param {Event & { target: LionOption }} ev
+ * @param {LionOption | null} el
*/
- _onChildActiveChanged({ target }) {
- if (target.active === true) {
- this.formElements.forEach(formElement => {
- if (formElement !== target) {
- // eslint-disable-next-line no-param-reassign
- formElement.active = false;
- }
- });
- this._listboxNode.setAttribute('aria-activedescendant', target.id);
+ __setChildActive(el) {
+ this.formElements.forEach(formElement => {
+ // eslint-disable-next-line no-param-reassign
+ formElement.active = el === formElement;
+ });
+ if (!el) {
+ this._activeDescendantOwnerNode.removeAttribute('aria-activedescendant');
+ return;
}
+ this._activeDescendantOwnerNode.setAttribute('aria-activedescendant', el.id);
+ if (!isInView(this._scrollTargetNode, el)) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }
+ }
+
+ /**
+ * @param {LionOption|LionOption[]} [exclude]
+ */
+ _uncheckChildren(exclude = []) {
+ const excludes = Array.isArray(exclude) ? exclude : [exclude];
+ this.formElements.forEach(option => {
+ if (!excludes.includes(option)) {
+ // eslint-disable-next-line no-param-reassign
+ option.checked = false;
+ }
+ });
}
/**
@@ -423,15 +619,8 @@ const ListboxMixinImplementation = superclass =>
if (cfgOrEvent.stopPropagation) {
cfgOrEvent.stopPropagation();
}
- if (target.checked) {
- if (!this.multipleChoice) {
- this.formElements.forEach(formElement => {
- if (formElement !== target) {
- // eslint-disable-next-line no-param-reassign
- formElement.checked = false;
- }
- });
- }
+ if (target.checked && !this.multipleChoice) {
+ this._uncheckChildren(target);
}
}
@@ -467,196 +656,51 @@ const ListboxMixinImplementation = superclass =>
* @param {number} currentIndex
* @param {number} offset
*/
- __getNextOption(currentIndex, offset) {
+ __getEnabledOption(currentIndex, offset) {
/**
* @param {number} i
*/
const until = i => (offset === 1 ? i < this.formElements.length : i >= 0);
+ // Try to find the next / previous option
for (let i = currentIndex + offset; until(i); i += offset) {
- if (this.formElements[i] && !this.formElements[i].disabled) {
+ if (this.formElements[i] && !this.formElements[i].hasAttribute('aria-hidden')) {
return i;
}
}
+ // If above was unsuccessful, try to find the next/previous either
+ // from end --> start or start --> end
if (this.rotateKeyboardNavigation) {
const startIndex = offset === -1 ? this.formElements.length - 1 : 0;
- for (let i = startIndex; until(i); i += 1) {
- if (this.formElements[i] && !this.formElements[i].disabled) {
+ for (let i = startIndex; until(i); i += offset) {
+ if (this.formElements[i] && !this.formElements[i].hasAttribute('aria-hidden')) {
return i;
}
}
}
+
+ // If above was unsuccessful, return currentIndex that we started with
return currentIndex;
}
/**
- * @param {number} currentIndex
- * @param {number} [offset=1]
+ * Moves options put in unnamed slot to slot with [role="listbox"]
*/
- _getNextEnabledOption(currentIndex, offset = 1) {
- return this.__getNextOption(currentIndex, offset);
- }
+ __moveOptionsToListboxNode() {
+ const slot = /** @type {HTMLSlotElement} */ (
+ /** @type {ShadowRoot} */ (this.shadowRoot).getElementById('options-outlet')
+ );
- /**
- * @param {number} currentIndex
- * @param {number} [offset=-1]
- */
- _getPreviousEnabledOption(currentIndex, offset = -1) {
- return this.__getNextOption(currentIndex, offset);
- }
-
- /**
- * @desc
- * Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
- * an item.
- *
- * @param {KeyboardEvent} ev - the keydown event object
- */
- _listboxOnKeyDown(ev) {
- if (this.disabled) {
- return;
- }
-
- const { key } = ev;
-
- switch (key) {
- case 'Enter':
- case ' ':
- ev.preventDefault();
- this.setCheckedIndex(this.activeIndex);
- break;
- case 'ArrowUp':
- ev.preventDefault();
- if (this.orientation === 'vertical') {
- this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
- }
- break;
- case 'ArrowLeft':
- ev.preventDefault();
- if (this.orientation === 'horizontal') {
- this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
- }
- break;
- case 'ArrowDown':
- ev.preventDefault();
- if (this.orientation === 'vertical') {
- this.activeIndex = this._getNextEnabledOption(this.activeIndex);
- }
- break;
- case 'ArrowRight':
- ev.preventDefault();
- if (this.orientation === 'horizontal') {
- this.activeIndex = this._getNextEnabledOption(this.activeIndex);
- }
- break;
- case 'Home':
- ev.preventDefault();
- this.activeIndex = this._getNextEnabledOption(0, 0);
- break;
- case 'End':
- ev.preventDefault();
- this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0);
- break;
- /* no default */
- }
-
- const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End'];
- if (keys.includes(key) && this.selectionFollowsFocus && !this.multipleChoice) {
- this.setCheckedIndex(this.activeIndex);
- }
- }
-
- // TODO: move to ChoiceGroupMixin?
- __requestOptionsToBeDisabled() {
- this.formElements.forEach(el => {
- if (el.makeRequestToBeDisabled) {
- el.makeRequestToBeDisabled();
- }
- });
- }
-
- __retractRequestOptionsToBeDisabled() {
- this.formElements.forEach(el => {
- if (el.retractRequestToBeDisabled) {
- el.retractRequestToBeDisabled();
- }
- });
- }
-
- /**
- * For ShadyDom the listboxNode is available right from the start so we can add those events
- * immediately.
- * For native ShadowDom the select gets render before the listboxNode is available so we
- * will add an event to the slotchange and add the events once available.
- */
- __setupListboxNode() {
- if (this._listboxNode) {
- this._setupListboxNodeInteractions();
- } else {
- const inputSlot = /** @type {ShadowRoot} */ (this.shadowRoot).querySelector(
- 'slot[name=input]',
- );
- if (inputSlot) {
- inputSlot.addEventListener('slotchange', () => {
- this._setupListboxNodeInteractions();
+ if (slot) {
+ slot.assignedNodes().forEach(node => {
+ this._listboxNode.appendChild(node);
+ });
+ slot.addEventListener('slotchange', () => {
+ slot.assignedNodes().forEach(node => {
+ this._listboxNode.appendChild(node);
});
- }
- }
- this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
- this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
- }
-
- /**
- * @overridable
- * @param {MouseEvent} ev
- */
- // eslint-disable-next-line class-methods-use-this, no-unused-vars
- _listboxOnClick(ev) {
- const option = /** @type {HTMLElement} */ (ev.target).closest('[role=option]');
- const foundIndex = this.formElements.indexOf(option);
- if (foundIndex > -1) {
- this.activIndex = foundIndex;
- }
- }
-
- /**
- * @overridable
- * @param {KeyboardEvent} ev
- */
- // eslint-disable-next-line class-methods-use-this, no-unused-vars
- _listboxOnKeyUp(ev) {
- if (this.disabled) {
- return;
- }
- const { key } = ev;
- // eslint-disable-next-line default-case
- switch (key) {
- case 'ArrowUp':
- case 'ArrowDown':
- case 'Home':
- case 'End':
- case ' ':
- case 'Enter':
- ev.preventDefault();
- }
- }
-
- _setupListboxNodeInteractions() {
- this._listboxNode.setAttribute('role', 'listbox');
- this._listboxNode.setAttribute('aria-orientation', this.orientation);
- this._listboxNode.setAttribute('aria-multiselectable', `${this.multipleChoice}`);
- this._listboxNode.setAttribute('tabindex', '0');
- this._listboxNode.addEventListener('click', this._listboxOnClick);
- this._listboxNode.addEventListener('keyup', this._listboxOnKeyUp);
- this._listboxNode.addEventListener('keydown', this._listboxOnKeyDown);
- }
-
- _teardownListboxNode() {
- if (this._listboxNode) {
- this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
- this._listboxNode.removeEventListener('click', this._listboxOnClick);
- this._listboxNode.removeEventListener('keyup', this._listboxOnKeyUp);
+ });
}
}
@@ -678,19 +722,40 @@ const ListboxMixinImplementation = superclass =>
}
}
- // TODO: move to FormControl / ValidateMixin?
/**
- * @param {string} value
+ * Helper method used within `._setupListboxNode`
*/
- set fieldName(value) {
- this.__fieldName = value;
+ __setupListboxNodeInteractions() {
+ this._listboxNode.setAttribute('role', 'listbox');
+ this._listboxNode.setAttribute('aria-orientation', this.orientation);
+ this._listboxNode.setAttribute('aria-multiselectable', `${this.multipleChoice}`);
+ this._listboxNode.setAttribute('tabindex', '0');
+ this._listboxNode.addEventListener('click', this._listboxOnClick);
+ this._listboxNode.addEventListener('keyup', this._listboxOnKeyUp);
+ this._listboxNode.addEventListener('keydown', this._listboxOnKeyDown);
+ /** Since _scrollTargetNode can be _listboxNode, handle here */
+ this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
}
- get fieldName() {
- const label =
- this.label ||
- (this.querySelector('[slot=label]') && this.querySelector('[slot=label]')?.textContent);
- return this.__fieldName || label || this.name;
+ // TODO: move to ChoiceGroupMixin?
+ __requestOptionsToBeDisabled() {
+ this.formElements.forEach(el => {
+ if (el.makeRequestToBeDisabled) {
+ el.makeRequestToBeDisabled();
+ }
+ });
+ }
+
+ __retractRequestOptionsToBeDisabled() {
+ this.formElements.forEach(el => {
+ if (el.retractRequestToBeDisabled) {
+ el.retractRequestToBeDisabled();
+ }
+ });
+ }
+
+ __initInteractionStates() {
+ this.initInteractionState();
}
};
diff --git a/packages/listbox/test-suites/ListboxMixin.suite.js b/packages/listbox/test-suites/ListboxMixin.suite.js
index 7588de250..1b6744adc 100644
--- a/packages/listbox/test-suites/ListboxMixin.suite.js
+++ b/packages/listbox/test-suites/ListboxMixin.suite.js
@@ -1,10 +1,15 @@
import { Required } from '@lion/form-core';
import { expect, html, fixture, unsafeStatic } from '@open-wc/testing';
-
-import '@lion/core/src/differentKeyEventNamesShimIE.js';
+import { LionOptions } from '@lion/listbox';
import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '../lion-listbox.js';
+import '@lion/core/src/differentKeyEventNamesShimIE.js';
+
+/**
+ * @typedef {import('@lion/combobox/src/LionCombobox').LionCombobox} LionCombobox
+ * @typedef {import('../src/LionListbox').LionListbox} LionListbox
+ */
/**
* @param { {tagString:string, optionTagString:string} } [customConfig]
@@ -20,242 +25,199 @@ export function runListboxMixinSuite(customConfig = {}) {
const optionTag = unsafeStatic(cfg.optionTagString);
describe('ListboxMixin', () => {
- it('has a single modelValue representing the currently checked option', async () => {
- const el = await fixture(html`
+ // 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}>
-
+ <${optionTag} .choiceValue=${'10'} checked>Item 1${optionTag}>
+ <${optionTag} .choiceValue=${'20'}>Item 2${optionTag}>
${tag}>
`);
- expect(el.modelValue).to.equal(10);
- });
+ expect(el.modelValue).to.equal('10');
+ });
- it('automatically sets the name attribute of child checkboxes to its own name', async () => {
- const el = await fixture(html`
+ 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}>
-
+ <${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');
+ 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);
+ const validChild = await fixture(
+ html` <${optionTag} .choiceValue=${30}>Item 3${optionTag}> `,
+ );
+ el.appendChild(validChild);
- expect(el.formElements[2].name).to.equal('foo');
- });
+ 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`
+ 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}>
-
+ <${optionTag} .choiceValue=${10} checked>Item 1${optionTag}>
+ <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
${tag}>
`);
- const invalidChild = await fixture(
- html` <${optionTag} .modelValue=${'Lara'}>${optionTag}> `,
- );
+ 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 }`,
- );
- });
+ 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('throws if a child element with a different name than the group tries to register', async () => {
- const el = await fixture(html`
+ it('throws if a child element with a different name than the group tries to register', async () => {
+ const el = await fixture(html`
<${tag} name="gender">
-
- <${optionTag} .choiceValue=${'female'} checked>${optionTag}>
- <${optionTag} .choiceValue=${'other'}>${optionTag}>
-
+ <${optionTag} .choiceValue=${'female'} checked>${optionTag}>
+ <${optionTag} .choiceValue=${'other'}>${optionTag}>
${tag}>
`);
- const invalidChild = await fixture(html`
+ const invalidChild = await fixture(html`
<${optionTag} name="foo" .choiceValue=${'male'}>${optionTag}>
`);
- expect(() => {
- el.addFormElement(invalidChild);
- }).to.throw(
- `The ${cfg.tagString} name="gender" does not allow to register lion-option with custom names (name="foo" given)`,
- );
- });
+ expect(() => {
+ el.addFormElement(invalidChild);
+ }).to.throw(
+ `The ${cfg.tagString} name="gender" does not allow to register lion-option with custom names (name="foo" given)`,
+ );
+ });
- it('can set initial modelValue on creation', async () => {
- const el = await fixture(html`
+ 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}>
-
+ <${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;
- });
+ expect(el.modelValue).to.equal('other');
+ expect(el.formElements[2].checked).to.be.true;
+ });
- it(`has a fieldName based on the label`, async () => {
- const el1 = await fixture(html`
- <${tag} label="foo">
${tag}>
+ 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);
+ expect(el1.fieldName).to.equal(el1._labelNode.textContent);
- const el2 = await fixture(html`
+ const el2 = await fixture(html`
<${tag}>
-
bar
+
bar
${tag}>
`);
- expect(el2.fieldName).to.equal(el2._labelNode.textContent);
- });
+ 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}>
+ 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);
- });
+ expect(el.fieldName).to.equal(el.name);
+ });
- it(`can override fieldName`, async () => {
- const el = await fixture(html`
- <${tag} label="foo" .fieldName="${'bar'}"
- >
${tag}>
+ it(`can override fieldName`, async () => {
+ const el = await fixture(html`
+ <${tag} label="foo" .fieldName="${'bar'}">${tag}>
`);
- expect(el.__fieldName).to.equal(el.fieldName);
- });
+ expect(el.__fieldName).to.equal(el.fieldName);
+ });
- it('does not have a tabindex', async () => {
- const el = await fixture(html`
- <${tag}>
-
- ${tag}>
- `);
- expect(el.hasAttribute('tabindex')).to.be.false;
- });
-
- it('delegates the name attribute to its children options', async () => {
- const el = await fixture(html`
- <${tag} name="foo">
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
-
- ${tag}>
- `);
-
- const optOne = el.querySelectorAll('lion-option')[0];
- const optTwo = el.querySelectorAll('lion-option')[1];
-
- expect(optOne.name).to.equal('foo');
- expect(optTwo.name).to.equal('foo');
- });
-
- it('supports validation', async () => {
- const el = await fixture(html`
+ 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}>
-
+ <${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;
+ 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;
+ // test submitted prop explicitly, since we dont extend field, we add the prop manually
+ el.submitted = true;
+ await el.updateComplete;
+ expect(el.showsFeedbackFor.includes('error')).to.be.true;
- el._listboxNode.children[1].checked = true;
- await el.updateComplete;
- expect(el.hasFeedbackFor.includes('error')).to.be.false;
- expect(el.showsFeedbackFor.includes('error')).to.be.false;
+ el._listboxNode.children[1].checked = true;
+ await el.updateComplete;
+ expect(el.hasFeedbackFor.includes('error')).to.be.false;
+ expect(el.showsFeedbackFor.includes('error')).to.be.false;
- el._listboxNode.children[0].checked = true;
- await el.updateComplete;
- expect(el.hasFeedbackFor.includes('error')).to.be.true;
- expect(el.showsFeedbackFor.includes('error')).to.be.true;
+ el._listboxNode.children[0].checked = true;
+ await el.updateComplete;
+ expect(el.hasFeedbackFor.includes('error')).to.be.true;
+ expect(el.showsFeedbackFor.includes('error')).to.be.true;
+ });
});
- it('supports having no default selection initially', async () => {
- const el = await fixture(html`
+ 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}>
-
+ <${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('');
- });
+ 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">
-
- <${optionTag} .choiceValue=${'red'}>Red${optionTag}>
- <${optionTag} .choiceValue=${'hotpink'}>Hotpink${optionTag}>
- <${optionTag} .choiceValue=${'teal'}>Teal${optionTag}>
-
+ 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(0);
- expect(el.serializedValue).to.equal('red');
+ expect(el.checkedIndex).to.equal(-1);
+ expect(el.serializedValue).to.equal('');
- el.serializedValue = 'hotpink';
+ el.serializedValue = 'hotpink';
- expect(el.checkedIndex).to.equal(1);
- expect(el.serializedValue).to.equal('hotpink');
+ expect(el.checkedIndex).to.equal(1);
+ expect(el.serializedValue).to.equal('hotpink');
+ });
});
describe('Accessibility', () => {
- it('is accessible when closed', async () => {
+ 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}>
-
+ <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
+ <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
${tag}>
`);
await expect(el).to.be.accessible();
});
- it('is accessible when opened', async () => {
+ 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}>
-
+ <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
+ <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
${tag}>
`);
el.opened = true;
@@ -264,9 +226,676 @@ export function runListboxMixinSuite(customConfig = {}) {
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="list">
+ <${optionTag} .choiceValue=${'10'} id="first">Item 1${optionTag}>
+ <${optionTag} .choiceValue=${'20'} checked id="second">Item 2${optionTag}>
+ ${tag}>
+ `);
+ expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.be.null;
+ await el.updateComplete;
+
+ // Normalize
+ el.activeIndex = 0;
+
+ // el._activeDescendantOwnerNode.dispatchEvent(
+ // 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' }),
+ );
+ await el.updateComplete;
+ expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal(
+ 'second',
+ );
+ });
+
+ it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
+ 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}>
+ `);
+ const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
+ optionEls.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}>
+ <${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) => {
+ expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`);
+ });
+ });
});
- describe('Use cases', () => {
+ 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}>
+ `);
+
+ 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('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}>
+ `);
+ expect(el.activeIndex).to.equal(-1);
+
+ el.querySelectorAll('lion-option')[1].active = true;
+ expect(el.querySelectorAll('lion-option')[0].active).to.be.false;
+ 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}>
+ `));
+ // Normalize
+ el.activeIndex = 0;
+ const options = el.formElements;
+ el._listboxNode.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' }));
+ 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}>
+ `));
+ // 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' }));
+ 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}>
+ `));
+ // 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: ' ' }));
+ expect(options[1].checked).to.be.true;
+ el.checkedIndex = 0;
+ el._listboxReceivesNoFocus = true;
+ el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
+ el._listboxNode.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.choiceValue).to.equal('a');
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' }));
+ expect(el.choiceValue).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.choiceValue).to.equal('far');
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' }));
+ expect(el.choiceValue).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}>
+ `);
+
+ if (el._listboxReceivesNoFocus) {
+ return;
+ }
+
+ el.activeIndex = 2;
+ el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
+ expect(el.activeIndex).to.equal(0);
+ el._listboxNode.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 = await fixture(html`
+ <${tag} opened has-no-default-selected>
+ <${optionTag} .choiceValue=${'Item 1'}>Item 1${optionTag}>
+ <${optionTag} .choiceValue=${'Item 2'}>Item 2${optionTag}>
+ <${optionTag} .choiceValue=${'Item 3'}>Item 3${optionTag}>
+ ${tag}>
+ `);
+ // 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);
+ el._listboxNode.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' }));
+ 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 {Listbox} */ (await fixture(html`
+ <${tag} opened name="foo" autocomplete="list">
+ <${optionTag} .choiceValue="${'Artichoke'}">Artichoke${optionTag}>
+ <${optionTag} .choiceValue="${'Chard'}">Chard${optionTag}>
+ ${tag}>
+ `));
+ 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;
+
+ el._listboxNode.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' }));
+ 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' }));
+ 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' }));
+ 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 {Listbox} */ (await fixture(html`
+ <${tag} opened name="foo" orientation="horizontal" autocomplete="list">
+ <${optionTag} .choiceValue="${'Artichoke'}">Artichoke${optionTag}>
+ <${optionTag} .choiceValue="${'Chard'}">Chard${optionTag}>
+ ${tag}>
+ `));
+ expect(el.orientation).to.equal('horizontal');
+
+ // Normalize for suite tests
+ el.activeIndex = 0;
+
+ await el.updateComplete;
+
+ el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
+ expect(el.activeIndex).to.equal(1);
+
+ el._listboxNode.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' }));
+ expect(el.activeIndex).to.equal(0);
+
+ el.activeIndex = 1;
+ el._listboxNode.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`
+ <${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');
+ });
+ });
+ });
+
+ describe('Multiple Choice', () => {
+ it('does not uncheck siblings', async () => {
+ const el = /** @type {Listbox} */ (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']);
+ });
+
+ describe('Accessibility', () => {
+ it('adds aria-multiselectable="true" to listbox node', async () => {
+ const el = /** @type {Listbox} */ (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');
+ });
+
+ it('does not allow "selectionFollowsFocus"', async () => {
+ const el = /** @type {Listbox} */ (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');
+ });
+ });
+ });
+
+ describe('Selection Follows Focus', () => {
+ it('navigates through list with [ArrowDown] [ArrowUp] keys: activates and checks the option', async () => {
+ 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 = await fixture(html`
+ <${tag} opened selection-follows-focus>
+ <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
+ <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
+ <${optionTag} .choiceValue=${30}>Item 3${optionTag}>
+ ${tag}>
+ `);
+ const options = Array.from(el.querySelectorAll('lion-option'));
+ // 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' }));
+ expect(el.activeIndex).to.equal(1);
+ expect(el.checkedIndex).to.equal(1);
+ expectOnlyGivenOneOptionToBeChecked(options, 1);
+ el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
+ 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}>
+ `);
+
+ if (el._listboxReceivesNoFocus) {
+ return;
+ }
+
+ expect(el.modelValue).to.equal('30');
+ el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
+ expect(el.modelValue).to.equal('10');
+ el._listboxNode.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}>
+ `);
+ await el.updateComplete;
+ const { checkedIndex } = el;
+ el._listboxNode.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.querySelectorAll('lion-option')];
+ 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.querySelectorAll('lion-option')];
+ 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}>
+ `);
+
+ // Normalize activeIndex across multiple implementers of ListboxMixinSuite
+ el.activeIndex = 0;
+
+ el._listboxNode.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' }));
+ // 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 opt = el.querySelectorAll('lion-option')[1];
+ opt.active = true;
+ expect(el._activeDescendantOwnerNode.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.querySelectorAll('lion-option')[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.querySelectorAll('lion-option');
+ 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.querySelectorAll('lion-option');
+ 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');
+ expect(el.validationStates).to.have.a.property('error');
+ expect(el.validationStates.error).to.have.a.property('Required');
+
+ el.modelValue = 20;
+ expect(el.hasFeedbackFor).not.to.include('error');
+ expect(el.validationStates).to.have.a.property('error');
+ 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 },
@@ -274,17 +903,16 @@ export function runListboxMixinSuite(customConfig = {}) {
];
const el = await fixture(html`
<${tag} label="Favorite color" name="color">
-
- ${objs.map(
- obj => html`
- <${optionTag} .modelValue=${{ value: obj, checked: false }}
- >${obj.label}${optionTag}
- >
- `,
- )}
-
+ ${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',
@@ -307,10 +935,10 @@ export function runListboxMixinSuite(customConfig = {}) {
let properlyInstantiated = false;
try {
- const el = document.createElement('lion-listbox');
+ const el = document.createElement(cfg.tagString);
const optionsEl = document.createElement('lion-options');
optionsEl.slot = 'input';
- const optionEl = document.createElement('lion-option');
+ const optionEl = document.createElement(cfg.optionTagString);
optionsEl.appendChild(optionEl);
el.appendChild(optionsEl);
properlyInstantiated = true;
@@ -320,479 +948,22 @@ export function runListboxMixinSuite(customConfig = {}) {
expect(properlyInstantiated).to.be.true;
});
- });
- });
- describe('lion-listbox interactions', () => {
- describe('values', () => {
- it('registers options', async () => {
+ it('can be instantiated without options', async () => {
const el = await fixture(html`
- <${tag}>
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
-
+ <${tag} name="foo">
+ <${optionTag} .choiceValue=${10} checked>Item 1${optionTag}>
+ <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
${tag}>
`);
+
+ expect(el._listboxNode).to.exist;
+ expect(el._listboxNode).to.be.instanceOf(LionOptions);
+ expect(el.querySelector('[role=listbox]')).to.equal(el._listboxNode);
+
expect(el.formElements.length).to.equal(2);
- expect(el.formElements).to.eql([
- el.querySelectorAll('lion-option')[0],
- el.querySelectorAll('lion-option')[1],
- ]);
- });
-
- it('has the first element by default checked and active', async () => {
- const el = await fixture(html`
- <${tag}>
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
-
- ${tag}>
- `);
-
- expect(el.querySelector('lion-option').checked).to.be.true;
- expect(el.querySelector('lion-option').active).to.be.true;
- expect(el.modelValue).to.equal(10);
-
- expect(el.checkedIndex).to.equal(0);
- expect(el.activeIndex).to.equal(0);
- });
-
- it('allows null choiceValue', async () => {
- const el = await fixture(html`
- <${tag}>
-
- <${optionTag} .choiceValue=${null}>Please select value${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
-
- ${tag}>
- `);
- expect(el.modelValue).to.be.null;
- });
-
- it('has the checked option as modelValue', 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);
- });
-
- it('has an activeIndex', async () => {
- const el = await fixture(html`
- <${tag}>
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
-
- ${tag}>
- `);
- expect(el.activeIndex).to.equal(0);
-
- el.querySelectorAll('lion-option')[1].active = true;
- expect(el.querySelectorAll('lion-option')[0].active).to.be.false;
- expect(el.activeIndex).to.equal(1);
- });
- });
-
- describe('Keyboard navigation', () => {
- it('does not allow to navigate above the first or below the last option', async () => {
- const el = await fixture(html`
- <${tag} opened>
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
-
- ${tag}>
- `);
- expect(() => {
- el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
- el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
- }).to.not.throw();
- expect(el.checkedIndex).to.equal(0);
- expect(el.activeIndex).to.equal(0);
- });
-
- // TODO: nice to have
- 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.choiceValue).to.equal('a');
- el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' }));
- expect(el.choiceValue).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.choiceValue).to.equal('far');
- el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' }));
- expect(el.choiceValue).to.equal('foo');
- });
- });
-
- describe('Keyboard navigation Mac', () => {
- it('navigates through open list with [ArrowDown] [ArrowUp] keys activates the option', async () => {
- const el = await fixture(html`
- <${tag} opened interaction-mode="mac">
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
- <${optionTag} .choiceValue=${30}>Item 3${optionTag}>
-
- ${tag}>
- `);
- expect(el.activeIndex).to.equal(0);
- expect(el.checkedIndex).to.equal(0);
-
- el._listboxNode.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: 'ArrowUp' }));
- expect(el.activeIndex).to.equal(0);
- expect(el.checkedIndex).to.equal(0);
- });
- });
-
- describe('Disabled', () => {
- it('still has a checked value', async () => {
- const el = await fixture(html`
- <${tag} disabled>
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
-
- ${tag}>
- `);
-
- expect(el.modelValue).to.equal(10);
- });
-
- it('cannot be navigated with keyboard if disabled', async () => {
- const el = await fixture(html`
- <${tag} disabled>
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
-
- ${tag}>
- `);
- el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
- expect(el.modelValue).to.equal(10);
- });
-
- it('skips disabled options while navigating through list with [ArrowDown] [ArrowUp] keys', async () => {
- const el = await fixture(html`
- <${tag} opened>
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20} disabled>Item 2${optionTag}>
- <${optionTag} .choiceValue=${30}>Item 3${optionTag}>
-
- ${tag}>
- `);
- el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
- expect(el.activeIndex).to.equal(2);
-
- el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
- expect(el.activeIndex).to.equal(0);
- });
-
- // flaky test
- it.skip('skips disabled options while navigates to first and last option with [Home] and [End] keys', async () => {
- const el = await fixture(html`
- <${tag} opened>
-
- <${optionTag} .choiceValue=${10} disabled>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
- <${optionTag} .choiceValue=${30} checked>Item 3${optionTag}>
- <${optionTag} .choiceValue=${40} disabled>Item 4${optionTag}>
-
- ${tag}>
- `);
- expect(el.activeIndex).to.equal(2);
-
- el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
- expect(el.activeIndex).to.equal(2);
-
- el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
- expect(el.activeIndex).to.equal(1);
- });
-
- it('checks the first enabled option', async () => {
- const el = await fixture(html`
- <${tag} opened>
-
- <${optionTag} .choiceValue=${10} disabled>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
- <${optionTag} .choiceValue=${30}>Item 3${optionTag}>
-
- ${tag}>
- `);
- expect(el.activeIndex).to.equal(1);
- expect(el.checkedIndex).to.equal(1);
- });
-
- 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.querySelectorAll('lion-option')];
- 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.querySelectorAll('lion-option')];
- 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('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 opt = el.querySelectorAll('lion-option')[1];
- opt.active = true;
- expect(el._listboxNode.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.querySelectorAll('lion-option')[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}>
-
- <${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(0);
- expect(el.activeIndex).to.equal(0);
- });
-
- 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.querySelectorAll('lion-option');
- expect(options[0].checked).to.be.true;
- options[1].checked = true;
- expect(options[0].checked).to.be.false;
- });
-
- it('unsets active on other options when option becomes active', async () => {
- const el = await fixture(html`
- <${tag}>
-
- <${optionTag} .choiceValue=${10}>Item 1${optionTag}>
- <${optionTag} .choiceValue=${20}>Item 2${optionTag}>
-
- ${tag}>
- `);
- const options = el.querySelectorAll('lion-option');
- expect(options[0].active).to.be.true;
- options[1].active = true;
- expect(options[0].active).to.be.false;
- });
- });
-
- 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} .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;
- });
- });
-
- 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');
- expect(el.validationStates).to.have.a.property('error');
- expect(el.validationStates.error).to.have.a.property('Required');
-
- el.modelValue = 20;
- expect(el.hasFeedbackFor).not.to.include('error');
- expect(el.validationStates).to.have.a.property('error');
- expect(el.validationStates.error).not.to.have.a.property('Required');
- });
- });
-
- describe('Accessibility', () => {
- 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 selected option', async () => {
- const el = await fixture(html`
- <${tag}>
-
- <${optionTag} .choiceValue=${10} id="first">Item 1${optionTag}>
- <${optionTag} .choiceValue=${20} checked id="second">Item 2${optionTag}>
-
- ${tag}>
- `);
-
- expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('first');
- el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
- expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('second');
- });
-
- it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
- 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}>
- `);
- const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
- optionEls.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}>
-
- <${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) => {
- expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`);
- });
+ expect(el._listboxNode.children.length).to.equal(2);
+ expect(el._listboxNode.children[0].tagName).to.equal(cfg.optionTagString.toUpperCase());
});
});
});
diff --git a/packages/listbox/types/ListboxMixinTypes.d.ts b/packages/listbox/types/ListboxMixinTypes.d.ts
index 4907e8740..dccc85336 100644
--- a/packages/listbox/types/ListboxMixinTypes.d.ts
+++ b/packages/listbox/types/ListboxMixinTypes.d.ts
@@ -55,7 +55,7 @@ export declare class ListboxHost {
protected _listboxOnKeyUp(ev: KeyboardEvent): void;
- protected _setupListboxNodeInteractions(): void;
+ protected _setupListboxNode(): void;
protected _teardownListboxNode(): void;
diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js
index 5ebaed326..e9d9bef3f 100644
--- a/packages/overlays/src/OverlayController.js
+++ b/packages/overlays/src/OverlayController.js
@@ -227,7 +227,7 @@ export class OverlayController extends EventTargetShim {
}
/**
- * The element that is placed behin the contentNode. When not provided and `hasBackdrop` is true,
+ * The element that is placed behind the contentNode. When not provided and `hasBackdrop` is true,
* a backdropNode will be automatically created
* @type {HTMLElement}
*/
@@ -312,7 +312,7 @@ export class OverlayController extends EventTargetShim {
}
/**
- * For non `isTooltip`:
+ * For non `isTooltip`:
* - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode
* - sets aria-controls on invokerNode
* - returns focus to invokerNode on hide
@@ -664,7 +664,8 @@ export class OverlayController extends EventTargetShim {
}
if (this.isShown) {
- /** @type {function} */ (this._showResolve)();
+ /** @type {function} */
+ (this._showResolve)();
return;
}
@@ -680,7 +681,8 @@ export class OverlayController extends EventTargetShim {
this.dispatchEvent(new Event('show'));
await this.transitionShow({ backdropNode: this.backdropNode, contentNode: this.contentNode });
}
- /** @type {function} */ (this._showResolve)();
+ /** @type {function} */
+ (this._showResolve)();
}
/**
diff --git a/packages/overlays/src/OverlayMixin.js b/packages/overlays/src/OverlayMixin.js
index 495e49d88..39f528fa3 100644
--- a/packages/overlays/src/OverlayMixin.js
+++ b/packages/overlays/src/OverlayMixin.js
@@ -62,10 +62,11 @@ export const OverlayMixinImplementation = superclass =>
* @returns {OverlayController}
*/
// eslint-disable-next-line
- _defineOverlay({ contentNode, invokerNode, backdropNode, contentWrapperNode }) {
+ _defineOverlay({ contentNode, invokerNode, referenceNode, backdropNode, contentWrapperNode }) {
return new OverlayController({
contentNode,
invokerNode,
+ referenceNode,
backdropNode,
contentWrapperNode,
...this._defineOverlayConfig(), // wc provided in the class as defaults
@@ -84,7 +85,7 @@ export const OverlayMixinImplementation = superclass =>
}
/**
- * @overridable method `_defineOverlay`
+ * @overridable method `_defineOverlayConfig`
* @desc returns an object with default configuration options for your overlay component.
* This is generally speaking easier to override than _defineOverlay method entirely.
* @returns {OverlayConfig}
@@ -97,7 +98,7 @@ export const OverlayMixinImplementation = superclass =>
}
/**
- * @param {{ has: (arg0: string) => any; }} changedProperties
+ * @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
@@ -168,6 +169,14 @@ export const OverlayMixinImplementation = superclass =>
return Array.from(this.children).find(child => child.slot === 'invoker');
}
+ /**
+ * @overridable
+ */
+ // eslint-disable-next-line class-methods-use-this
+ get _overlayReferenceNode() {
+ return undefined;
+ }
+
get _overlayBackdropNode() {
return Array.from(this.children).find(child => child.slot === 'backdrop');
}
@@ -191,6 +200,7 @@ export const OverlayMixinImplementation = superclass =>
contentNode: this._overlayContentNode,
contentWrapperNode: this._overlayContentWrapperNode,
invokerNode: this._overlayInvokerNode,
+ referenceNode: this._overlayReferenceNode,
backdropNode: this._overlayBackdropNode,
});
this.__syncToOverlayController();
@@ -270,7 +280,8 @@ export const OverlayMixinImplementation = superclass =>
}
__teardownSyncFromOverlayController() {
- /** @type {OverlayController} */ (this._overlayCtrl).removeEventListener(
+ /** @type {OverlayController} */
+ (this._overlayCtrl).removeEventListener(
'show',
/** @type {EventListener} */ (this.__onOverlayCtrlShow),
);
@@ -290,9 +301,11 @@ export const OverlayMixinImplementation = superclass =>
__syncToOverlayController() {
if (this.opened) {
- /** @type {OverlayController} */ (this._overlayCtrl).show();
+ /** @type {OverlayController} */
+ (this._overlayCtrl).show();
} else {
- /** @type {OverlayController} */ (this._overlayCtrl).hide();
+ /** @type {OverlayController} */
+ (this._overlayCtrl).hide();
}
}
};
diff --git a/packages/select-rich/README.md b/packages/select-rich/README.md
index adf957c07..9415528f6 100644
--- a/packages/select-rich/README.md
+++ b/packages/select-rich/README.md
@@ -26,11 +26,9 @@ loadDefaultFeedbackMessages();
```js preview-story
export const main = () => html`
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
`;
```
@@ -78,20 +76,18 @@ The main feature of this rich select that makes it rich, is that your options ca
```js preview-story
export const optionsWithHTML = () => html`
-
-
- I am red
- and multi Line
-
-
- I am hotpink
- and multi Line
-
-
- I am teal
- and multi Line
-
-
+
+ I am red
+ and multi Line
+
+
+ I am hotpink
+ and multi Line
+
+
+ I am teal
+ and multi Line
+
`;
```
@@ -108,23 +104,21 @@ export const manyOptionsWithScrolling = () => html`
}
-
-
- I am red
-
-
- I am hotpink
-
-
- I am teal
-
-
- I am green
-
-
- I am blue
-
-
+
+ I am red
+
+
+ I am hotpink
+
+
+ I am teal
+
+
+ I am green
+
+
+ I am blue
+
`;
```
@@ -139,11 +133,9 @@ The readonly attribute is delegated to the invoker for disabling opening the ove
```js preview-story
export const readOnlyPrefilled = () => html`
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
`;
```
@@ -157,11 +149,9 @@ If you disable the entire select, the disabled attribute is also delegated to th
```js preview-story
export const disabledSelect = () => html`
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
`;
```
@@ -169,13 +159,11 @@ export const disabledSelect = () => html`
```js preview-story
export const disabledOption = () => html`
-
- Red
- Blue
- Hotpink
- Green
- Teal
-
+ Red
+ Blue
+ Hotpink
+ Green
+ Teal
`;
```
@@ -194,12 +182,10 @@ export const validation = () => {
label="Favorite color"
.validators="${[new Required()]}"
>
-
- select a color
- Red
- Hotpink
- Teal
-
+
select a color
+
Red
+
Hotpink
+
Teal
`;
};
@@ -217,22 +203,20 @@ export const renderOptions = () => {
{ type: 'mastercard', label: 'Master Card', amount: 12000, active: true },
{ type: 'visacard', label: 'Visa Card', amount: 0, active: false },
];
- function showOutput() {
+ function showOutput(ev) {
document.getElementById('demoRenderOutput').innerHTML = JSON.stringify(
- this.checkedValue,
+ ev.target.modelValue,
null,
2,
);
}
return html`
-
-
- ${objs.map(
- obj => html`
- ${obj.label}
- `,
- )}
-
+
+ ${objs.map(
+ obj => html`
+ ${obj.label}
+ `,
+ )}
Full value:
@@ -250,18 +234,14 @@ This changes the keyboard interaction.
```js preview-story
export const interactionMode = () => html`
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
`;
```
@@ -302,11 +282,9 @@ export const checkedIndexAndValue = () => html`
Console log checked index and value
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
`;
```
@@ -331,11 +309,9 @@ Both methods work with the `Required` validator.
```js preview-story
export const noDefaultSelection = () => html`
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
`;
```
@@ -350,9 +326,7 @@ If there is a single option rendered, then `singleOption` property is set to `tr
```js preview-story
export const singleOption = () => html`
-
- Red
-
+ Red
`;
```
@@ -416,7 +390,7 @@ You can use this `selectedElement` to then render the content to your own invoke
```html
- ...
+ ...
```
diff --git a/packages/select-rich/src/LionSelectInvoker.js b/packages/select-rich/src/LionSelectInvoker.js
index a688b99be..0356008bf 100644
--- a/packages/select-rich/src/LionSelectInvoker.js
+++ b/packages/select-rich/src/LionSelectInvoker.js
@@ -8,7 +8,6 @@ import { css, html } from '@lion/core';
/**
* LionSelectInvoker: invoker button consuming a selected element
*/
-// @ts-expect-error static get sryles return type
export class LionSelectInvoker extends LionButton {
static get styles() {
return [
diff --git a/packages/select-rich/src/LionSelectRich.js b/packages/select-rich/src/LionSelectRich.js
index b9fd16651..2a4abae9e 100644
--- a/packages/select-rich/src/LionSelectRich.js
+++ b/packages/select-rich/src/LionSelectRich.js
@@ -1,5 +1,5 @@
import { LionListbox } from '@lion/listbox';
-import { html, ScopedElementsMixin, SlotMixin } from '@lion/core';
+import { html, ScopedElementsMixin, SlotMixin, browserDetection } from '@lion/core';
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import { LionSelectInvoker } from './LionSelectInvoker.js';
@@ -13,7 +13,7 @@ import { LionSelectInvoker } from './LionSelectInvoker.js';
*/
function detectInteractionMode() {
- if (navigator.appVersion.indexOf('Mac') !== -1) {
+ if (browserDetection.isMac) {
return 'mac';
}
return 'windows/linux';
@@ -22,7 +22,6 @@ function detectInteractionMode() {
/**
* LionSelectRich: wraps the element
*/
-// @ts-expect-error base constructors same return type
export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) {
static get scopedElements() {
return {
@@ -64,7 +63,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
}
get _scrollTargetNode() {
- // @ts-expect-error _scrollTargetNode not on type
return this._overlayContentNode._scrollTargetNode || this._overlayContentNode;
}
@@ -100,7 +98,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this.__overlayOnShow = this.__overlayOnShow.bind(this);
this.__invokerOnClick = this.__invokerOnClick.bind(this);
this.__overlayBeforeShow = this.__overlayBeforeShow.bind(this);
- this.__focusInvokerOnLabelClick = this.__focusInvokerOnLabelClick.bind(this);
this._listboxOnClick = this._listboxOnClick.bind(this);
}
@@ -109,18 +106,11 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
this.__setupInvokerNode();
this.__toggleInvokerDisabled();
- if (this._labelNode) {
- this._labelNode.addEventListener('click', this.__focusInvokerOnLabelClick);
- }
-
this.addEventListener('keyup', this.__onKeyUp);
}
disconnectedCallback() {
super.disconnectedCallback();
- if (this._labelNode) {
- this._labelNode.removeEventListener('click', this.__focusInvokerOnLabelClick);
- }
this.__teardownInvokerNode();
this.removeEventListener('keyup', this.__onKeyUp);
}
@@ -145,6 +135,30 @@ 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.
@@ -159,16 +173,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this.initInteractionState();
}
- /**
- * @override
- * @param {FormControl} child the child element (field)
- * @param {number} indexToInsertAt index to insert the form element at
- */
- addFormElement(child, indexToInsertAt) {
- super.addFormElement(child, indexToInsertAt);
- this._onFormElementsChanged();
- }
-
/**
* @param {FormRegisteringHost} child the child element (field)
*/
@@ -178,14 +182,8 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
}
_onFormElementsChanged() {
- if (this.formElements.length === 1 && this.singleOption === false) {
- this.singleOption = true;
- this._invokerNode.singleOption = true;
- }
- if (this.formElements.length !== 1 && this.singleOption === true) {
- this.singleOption = false;
- this._invokerNode.singleOption = false;
- }
+ this.singleOption = this.formElements.length === 1;
+ this._invokerNode.singleOption = this.singleOption;
}
/**
@@ -243,6 +241,7 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
+
`;
@@ -350,7 +349,10 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
}
- __focusInvokerOnLabelClick() {
+ /**
+ * @configure FormControlMixin
+ */
+ _onLabelClick() {
this._invokerNode.focus();
}
@@ -441,8 +443,8 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this.opened = false;
}
- _setupListboxNodeInteractions() {
- super._setupListboxNodeInteractions();
+ _setupListboxNode() {
+ super._setupListboxNode();
this._listboxNode.addEventListener('click', this._listboxOnClick);
}
diff --git a/packages/select-rich/test/lion-select-rich-interaction.test.js b/packages/select-rich/test/lion-select-rich-interaction.test.js
index 17456f300..a6d25fc1a 100644
--- a/packages/select-rich/test/lion-select-rich-interaction.test.js
+++ b/packages/select-rich/test/lion-select-rich-interaction.test.js
@@ -1,5 +1,6 @@
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';
@@ -7,66 +8,56 @@ import '@lion/listbox/lion-options.js';
import '../lion-select-rich.js';
describe('lion-select-rich interactions', () => {
- describe('Keyboard navigation', () => {
- it('navigates to first and last option with [Home] and [End] keys', async () => {
+ describe('Interaction mode', () => {
+ it('autodetects interactionMode if not defined', async () => {
+ const originalIsMac = browserDetection.isMac;
+
+ browserDetection.isMac = true;
const el = await fixture(html`
-
-
- Item 1
- Item 2
- Item 3
- Item 4
-
-
+
Item 1
`);
- expect(el.modelValue).to.equal(30);
+ expect(el.interactionMode).to.equal('mac');
+ const el2 = await fixture(html`
+
Item 1
+ `);
+ expect(el2.interactionMode).to.equal('windows/linux');
- el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
- expect(el.modelValue).to.equal(10);
+ browserDetection.isMac = false;
+ const el3 = await fixture(html`
+
Item 1
+ `);
+ expect(el3.interactionMode).to.equal('windows/linux');
+ const el4 = await fixture(html`
+
Item 1
+ `);
+ expect(el4.interactionMode).to.equal('mac');
+ browserDetection.isMac = originalIsMac;
+ });
- el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
- expect(el.modelValue).to.equal(40);
+ it('derives selectionFollowsFocus and navigateWithinInvoker from interactionMode', async () => {
+ const el = await fixture(html`
+
Item 1
+ `);
+ expect(el.selectionFollowsFocus).to.be.true;
+ expect(el.navigateWithinInvoker).to.be.true;
+
+ const el2 = await fixture(html`
+
Item 1
+ `);
+ expect(el2.selectionFollowsFocus).to.be.false;
+ expect(el2.navigateWithinInvoker).to.be.false;
});
});
- describe('Keyboard navigation Windows', () => {
- it('navigates through list with [ArrowDown] [ArrowUp] keys activates and checks the option', async () => {
- 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 = await fixture(html`
-
-
- Item 1
- Item 2
- Item 3
-
-
- `);
-
- const options = Array.from(el.querySelectorAll('lion-option'));
- expect(el.activeIndex).to.equal(0);
- expect(el.checkedIndex).to.equal(0);
- expectOnlyGivenOneOptionToBeChecked(options, 0);
-
- el._listboxNode.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' }));
- expect(el.activeIndex).to.equal(0);
- expect(el.checkedIndex).to.equal(0);
- expectOnlyGivenOneOptionToBeChecked(options, 0);
- });
-
+ describe('Invoker Keyboard navigation Windows', () => {
it('navigates through list with [ArrowDown] [ArrowUp] keys checks the option while listbox unopened', async () => {
function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) {
options.forEach((option, i) => {
@@ -103,7 +94,7 @@ describe('lion-select-rich interactions', () => {
});
describe('Disabled', () => {
- it('cannot be focused if disabled', async () => {
+ it('invoker cannot be focused if disabled', async () => {
const el = await fixture(html`
diff --git a/packages/select-rich/test/lion-select-rich.test.js b/packages/select-rich/test/lion-select-rich.test.js
index 3153f0433..157d123ba 100644
--- a/packages/select-rich/test/lion-select-rich.test.js
+++ b/packages/select-rich/test/lion-select-rich.test.js
@@ -18,23 +18,39 @@ import '../lion-select-rich.js';
describe('lion-select-rich', () => {
it('clicking the label should focus the invoker', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
expect(document.activeElement === document.body).to.be.true;
el._labelNode.click();
expect(document.activeElement === el._invokerNode).to.be.true;
});
+ it('checks the first enabled option', async () => {
+ const el = await fixture(html`
+
+
+
+
+
+ `);
+ 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`
+
+ Red
+ Hotpink
+ Blue
+
+ `);
+
+ expect(el.modelValue).to.equal('Red');
+ });
+
describe('Invoker', () => {
it('generates an lion-select-invoker if no invoker is provided', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
expect(el._invokerNode).to.exist;
expect(el._invokerNode.tagName).to.include('LION-SELECT-INVOKER');
@@ -43,10 +59,8 @@ describe('lion-select-rich', () => {
it('sets the first option as the selectedElement if no option is checked', async () => {
const el = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
const options = Array.from(el.querySelectorAll('lion-option'));
@@ -56,10 +70,8 @@ describe('lion-select-rich', () => {
it('syncs the selected element to the invoker', async () => {
const el = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
const options = el.querySelectorAll('lion-option');
@@ -73,34 +85,35 @@ describe('lion-select-rich', () => {
it('delegates readonly to the invoker', async () => {
const el = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
expect(el.hasAttribute('readonly')).to.be.true;
expect(el._invokerNode.hasAttribute('readonly')).to.be.true;
});
+
+ it('delegates singleOption to the invoker', async () => {
+ const el = await fixture(html`
+
+ Item 1
+
+ `);
+
+ expect(el.singleOption).to.be.true;
+ expect(el._invokerNode.hasAttribute('single-option')).to.be.true;
+ });
});
describe('overlay', () => {
it('should be closed by default', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
expect(el.opened).to.be.false;
});
it('shows/hides the listbox via opened attribute', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
el.opened = true;
await el.updateComplete;
expect(el._overlayCtrl.isShown).to.be.true;
@@ -111,11 +124,7 @@ describe('lion-select-rich', () => {
});
it('syncs opened state with overlay shown', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
const outerEl = await fixture('somewhere ');
expect(el.opened).to.be.true;
@@ -127,11 +136,7 @@ describe('lion-select-rich', () => {
});
it('will focus the listbox on open and invoker on close', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
await el._overlayCtrl.show();
await el.updateComplete;
expect(document.activeElement === el._listboxNode).to.be.true;
@@ -146,10 +151,8 @@ describe('lion-select-rich', () => {
it('opens the listbox with checked option as active', async () => {
const el = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
await el._overlayCtrl.show();
@@ -163,27 +166,21 @@ describe('lion-select-rich', () => {
it('stays closed on click if it is disabled or readonly or has a single option', async () => {
const elReadOnly = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
const elDisabled = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
const elSingleoption = await fixture(html`
-
- Item 1
-
+ Item 1
`);
@@ -203,11 +200,9 @@ describe('lion-select-rich', () => {
it('sets inheritsReferenceWidth to min by default', async () => {
const el = await fixture(html`
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
`);
@@ -220,11 +215,9 @@ describe('lion-select-rich', () => {
it('should override the inheritsWidth prop when no default selected feature is used', async () => {
const el = await fixture(html`
-
- Red
- Hotpink
- Teal
-
+ Red
+ Hotpink
+ Teal
`);
@@ -252,10 +245,8 @@ describe('lion-select-rich', () => {
it('should have singleOption only if there is exactly one option', async () => {
const el = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
expect(el.singleOption).to.be.false;
@@ -281,9 +272,7 @@ describe('lion-select-rich', () => {
describe('interaction-mode', () => {
it('allows to specify an interaction-mode which determines other behaviors', async () => {
const el = await fixture(html`
-
-
-
+
`);
expect(el.interactionMode).to.equal('mac');
});
@@ -291,43 +280,27 @@ describe('lion-select-rich', () => {
describe('Keyboard navigation', () => {
it('opens the listbox with [Enter] key via click handler', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
el._invokerNode.click();
await aTimeout();
expect(el.opened).to.be.true;
});
it('opens the listbox with [ ](Space) key via click handler', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
el._invokerNode.click();
await aTimeout();
expect(el.opened).to.be.true;
});
it('closes the listbox with [Escape] key once opened', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
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`
-
-
-
- `);
+ const el = await fixture(html` `);
// tab can only be caught via keydown
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
expect(el.opened).to.be.false;
@@ -336,11 +309,7 @@ describe('lion-select-rich', () => {
describe('Mouse navigation', () => {
it('opens the listbox via click on invoker', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
expect(el.opened).to.be.false;
el._invokerNode.click();
await nextFrame(); // reflection of click takes some time
@@ -350,9 +319,7 @@ describe('lion-select-rich', () => {
it('closes the listbox when an option gets clicked', async () => {
const el = await fixture(html`
-
- Item 1
-
+ Item 1
`);
expect(el.opened).to.be.true;
@@ -363,11 +330,7 @@ describe('lion-select-rich', () => {
describe('Keyboard navigation Windows', () => {
it('closes the listbox with [Enter] key once opened', async () => {
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.opened).to.be.false;
});
@@ -377,10 +340,8 @@ describe('lion-select-rich', () => {
it('checks active item and closes the listbox with [Enter] key via click handler once opened', async () => {
const el = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
@@ -395,9 +356,7 @@ describe('lion-select-rich', () => {
it('opens the listbox with [ArrowUp] key', async () => {
const el = await fixture(html`
-
-
-
+
`);
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
await el.updateComplete;
@@ -406,9 +365,7 @@ describe('lion-select-rich', () => {
it('opens the listbox with [ArrowDown] key', async () => {
const el = await fixture(html`
-
-
-
+
`);
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
await el.updateComplete;
@@ -420,10 +377,8 @@ describe('lion-select-rich', () => {
it('has the right references to its inner elements', async () => {
const el = await fixture(html`
-
- Item 1
- Item 2
-
+ Item 1
+ Item 2
`);
expect(el._invokerNode.getAttribute('aria-labelledby')).to.contain(el._labelNode.id);
@@ -435,11 +390,7 @@ describe('lion-select-rich', () => {
it('notifies when the listbox is expanded or not', async () => {
// smoke test for overlay functionality
- const el = await fixture(html`
-
-
-
- `);
+ const el = await fixture(html` `);
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false');
el.opened = true;
await el.updateComplete;
@@ -483,16 +434,13 @@ describe('lion-select-rich', () => {
render() {
return html`
-
- ${this.colorList.map(
- colorObj => html`
- ${colorObj.label}
- `,
- )}
-
+ ${this.colorList.map(
+ colorObj => html`
+ ${colorObj.label}
+ `,
+ )}
`;
}
@@ -545,13 +493,13 @@ describe('lion-select-rich', () => {
const el = await fixture(html`
<${mySelectTag} label="Favorite color" name="color">
-
+
${Array(2).map(
(_, i) => html`
value ${i}
`,
)}
-
+
${mySelectTag}>
`);
await el.updateComplete;
@@ -584,11 +532,11 @@ describe('lion-select-rich', () => {
const el = await fixture(html`
<${selectTag} id="color" name="color" label="Favorite color" has-no-default-selected>
-
+
Red
Hotpink
Teal
-
+
${selectTag}>
`);