diff --git a/.changeset/nasty-rules-explain.md b/.changeset/nasty-rules-explain.md new file mode 100644 index 000000000..7ad1f6e5c --- /dev/null +++ b/.changeset/nasty-rules-explain.md @@ -0,0 +1,6 @@ +--- +'@lion/listbox': minor +'@lion/select-rich': patch +--- + +listbox package diff --git a/.changeset/nervous-timers-attend.md b/.changeset/nervous-timers-attend.md index 58df2c0bd..55326ee86 100644 --- a/.changeset/nervous-timers-attend.md +++ b/.changeset/nervous-timers-attend.md @@ -1,6 +1,6 @@ --- '@lion/button': patch -'@lion/overlays': patch +'@lion/overlays': minor '@lion/tooltip': patch --- diff --git a/packages/listbox/CHANGELOG.md b/packages/listbox/CHANGELOG.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/packages/listbox/CHANGELOG.md @@ -0,0 +1 @@ + diff --git a/packages/listbox/README.md b/packages/listbox/README.md new file mode 100644 index 000000000..4127230b4 --- /dev/null +++ b/packages/listbox/README.md @@ -0,0 +1,250 @@ +# Listbox + +A listbox widget presents a list of options and allows a user to select one or more of them. +A listbox that allows a single option to be chosen is a single-select listbox; one that allows +multiple options to be selected is a multi-select listbox. + +> From [listbox wai-aria best practices](https://www.w3.org/TR/wai-aria-practices/#Listbox) + +```js script +import { html } from 'lit-html'; +import { Required } from '@lion/form-core'; +import { loadDefaultFeedbackMessages } from '@lion/validate-messages'; +import { listboxData } from './docs/listboxData.js'; +import './lion-option.js'; +import './lion-listbox.js'; + +export default { + title: 'Forms/Listbox', +}; +``` + +```js preview-story +export const main = () => html` + + Artichoke + Banana + + Artichoke + Banana + Bell pepper + Brussels sprout + Carrot + +`; +``` + +## Orientation + +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. + +```js preview-story +export const orientationHorizontal = () => html` + + Artichoke + Banana + Bell pepper + Brussels sprout + Carrot + +`; +``` + +With `multiple-choice` flag configured, multiple options can be checked. + +```js preview-story +export const orientationHorizontalMultiple = () => html` + + Artichoke + Banana + Bell pepper + Brussels sprout + Carrot + +`; +``` + +## Selection-follows-focus + +When true, will synchronize activedescendant and selected element on arrow key navigation. +This behavior can usually be seen in ` on the Windows platform. + * Note that this behavior cannot be used when multiple-choice is true. + * See: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus + */ + this.selectionFollowsFocus = false; + + /** @type {number | null} */ + this._listboxActiveDescendant = null; + this.__hasInitialSelectedFormElement = false; + this._repropagationRole = 'choice-group'; // configures FormControlMixin + + /** @type {EventListener} */ + this._listboxOnKeyDown = this._listboxOnKeyDown.bind(this); + /** @type {EventListener} */ + this._listboxOnClick = this._listboxOnClick.bind(this); + /** @type {EventListener} */ + this._listboxOnKeyUp = this._listboxOnKeyUp.bind(this); + /** @type {EventListener} */ + this._onChildActiveChanged = this._onChildActiveChanged.bind(this); + /** @type {EventListener} */ + this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this); + } + + connectedCallback() { + if (this._listboxNode) { + // if there is none yet, it will be supplied via static get slots + this._listboxNode.registrationTarget = this; + } + super.connectedCallback(); + this.__setupListboxNode(); + this.__setupEventListeners(); + + this.registrationComplete.then(() => { + this.__initInteractionStates(); + }); + } + + /** + * @param {import('lit-element').PropertyValues } changedProperties + */ + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + + this.__moveOptionsToListboxNode(); + } + + /** + * Moves options put in regulat slot to slot wiht role=listbox + */ + __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); + }); + }); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this._teardownListboxNode(); + this.__teardownEventListeners(); + } + + /** + * In the select disabled options are still going to a possible value for example + * when prefilling or programmatically setting it. + * + * @override + */ + _getCheckedElements() { + return this.formElements.filter(el => el.checked); + } + + __initInteractionStates() { + this.initInteractionState(); + } + + // 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(); + } else { + // this._invokerNode.retractRequestToBeDisabled(); + this.__retractRequestOptionsToBeDisabled(); + } + } + } + + /** + * @override + */ + // eslint-disable-next-line + _inputGroupInputTemplate() { + return html` +
+ + +
+ `; + } + + /** + * Overrides FormRegistrar adding to make sure children have specific default states when added + * + * @override + * @param {LionOption} child + * @param {Number} indexToInsertAt + */ + // @ts-expect-error + 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()}`; + + if (this.disabled) { + 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); + this.formElements.forEach((el, idx) => { + el.setAttribute('aria-posinset', idx + 1); + }); + + this.__proxyChildModelValueChanged( + /** @type {Event & { target: LionOption; }} */ ({ target: child }), + ); + this.resetInteractionState(); + /* eslint-enable no-param-reassign */ + } + + __setupEventListeners() { + this._listboxNode.addEventListener( + 'active-changed', + /** @type {EventListener} */ (this._onChildActiveChanged), + ); + this._listboxNode.addEventListener( + 'model-value-changed', + /** @type {EventListener} */ (this.__proxyChildModelValueChanged), + ); + + // this._listboxNode.addEventListener('checked-changed', this.__onChildCheckedChanged); + } + + __teardownEventListeners() { + this._listboxNode.removeEventListener( + 'active-changed', + /** @type {EventListener} */ (this._onChildActiveChanged), + ); + this._listboxNode.removeEventListener( + 'model-value-changed', + /** @type {EventListener} */ (this.__proxyChildModelValueChanged), + ); + } + + /** + * @param {Event & { target: LionOption }} ev + */ + _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); + } + } + + /** + * @param {Event & { target: LionOption }} cfgOrEvent + */ + __onChildCheckedChanged(cfgOrEvent) { + const { target } = cfgOrEvent; + 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; + } + }); + } + } + } + + /** + * // TODO: add to choiceGroup + * @param {string} attribute + * @param {number} value + */ + __setAttributeForAllFormElements(attribute, value) { + this.formElements.forEach(formElement => { + formElement.setAttribute(attribute, value); + }); + } + + /** + * @param {Event & { target: LionOption; }} ev + */ + __proxyChildModelValueChanged(ev) { + // We need to redispatch the model-value-changed event on 'this', so it will + // align with FormControl.__repropagateChildrenValues method. Also, this makes + // it act like a portal, in case the listbox is put in a modal overlay on body level. + if (ev.stopPropagation) { + ev.stopPropagation(); + } + this.__onChildCheckedChanged(ev); + this.requestUpdate('modelValue'); + this.dispatchEvent( + new CustomEvent('model-value-changed', { detail: { element: ev.target } }), + ); + } + + /** + * @param {number} currentIndex + * @param {number} offset + */ + __getNextOption(currentIndex, offset) { + /** + * @param {number} i + */ + const until = i => (offset === 1 ? i < this.formElements.length : i >= 0); + + for (let i = currentIndex + offset; until(i); i += offset) { + if (this.formElements[i] && !this.formElements[i].disabled) { + return i; + } + } + + 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) { + return i; + } + } + } + return currentIndex; + } + + /** + * @param {number} currentIndex + * @param {number} [offset=1] + */ + _getNextEnabledOption(currentIndex, offset = 1) { + return this.__getNextOption(currentIndex, offset); + } + + /** + * @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(); + }); + } + } + 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); + } + } + + /** + * @param {KeyboardEvent} ev + */ + __preventScrollingWithArrowKeys(ev) { + if (this.disabled) { + return; + } + const { key } = ev; + switch (key) { + case 'ArrowUp': + case 'ArrowDown': + case 'Home': + case 'End': + ev.preventDefault(); + /* no default */ + } + } + + // TODO: move to FormControl / ValidateMixin? + /** + * @param {string} value + */ + set fieldName(value) { + this.__fieldName = value; + } + + get fieldName() { + const label = + this.label || + (this.querySelector('[slot=label]') && this.querySelector('[slot=label]')?.textContent); + return this.__fieldName || label || this.name; + } + }; + +export const ListboxMixin = dedupeMixin(ListboxMixinImplementation); diff --git a/packages/listbox/test-suites/ListboxMixin.suite.js b/packages/listbox/test-suites/ListboxMixin.suite.js new file mode 100644 index 000000000..7588de250 --- /dev/null +++ b/packages/listbox/test-suites/ListboxMixin.suite.js @@ -0,0 +1,799 @@ +import { Required } from '@lion/form-core'; +import { expect, html, fixture, unsafeStatic } from '@open-wc/testing'; + +import '@lion/core/src/differentKeyEventNamesShimIE.js'; +import '@lion/listbox/lion-option.js'; +import '@lion/listbox/lion-options.js'; +import '../lion-listbox.js'; + +/** + * @param { {tagString:string, optionTagString:string} } [customConfig] + */ +export function runListboxMixinSuite(customConfig = {}) { + const cfg = { + tagString: 'lion-listbox', + optionTagString: 'lion-option', + ...customConfig, + }; + + const tag = unsafeStatic(cfg.tagString); + const optionTag = unsafeStatic(cfg.optionTagString); + + describe('ListboxMixin', () => { + 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} .choiceValue=${20}>Item 2 + + + `); + + expect(el.modelValue).to.equal(10); + }); + + it('automatically sets the name attribute of child checkboxes to its own name', async () => { + const el = await fixture(html` + <${tag} name="foo"> + + <${optionTag} .choiceValue=${10} checked>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + + `); + + 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 `, + ); + el.appendChild(validChild); + + expect(el.formElements[2].name).to.equal('foo'); + }); + + it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => { + const el = await fixture(html` + <${tag} name="foo"> + + <${optionTag} .choiceValue=${10} checked>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + + `); + const invalidChild = await fixture( + html` <${optionTag} .modelValue=${'Lara'}> `, + ); + + 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` + <${tag} name="gender"> + + <${optionTag} .choiceValue=${'female'} checked> + <${optionTag} .choiceValue=${'other'}> + + + `); + const invalidChild = await fixture(html` + <${optionTag} name="foo" .choiceValue=${'male'}> + `); + + 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` + <${tag} name="gender" .modelValue=${'other'}> + + <${optionTag} .choiceValue=${'male'}> + <${optionTag} .choiceValue=${'female'}> + <${optionTag} .choiceValue=${'other'}> + + + `); + + 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"> + `); + expect(el1.fieldName).to.equal(el1._labelNode.textContent); + + const el2 = await fixture(html` + <${tag}> + + + `); + expect(el2.fieldName).to.equal(el2._labelNode.textContent); + }); + + it(`has a fieldName based on the name if no label exists`, async () => { + const el = await fixture(html` + <${tag} name="foo"> + `); + expect(el.fieldName).to.equal(el.name); + }); + + it(`can override fieldName`, async () => { + const el = await fixture(html` + <${tag} label="foo" .fieldName="${'bar'}" + > + `); + expect(el.__fieldName).to.equal(el.fieldName); + }); + + it('does not have a tabindex', async () => { + const el = await fixture(html` + <${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} .choiceValue=${20}>Item 2 + + + `); + + const optOne = el.querySelectorAll('lion-option')[0]; + const optTwo = el.querySelectorAll('lion-option')[1]; + + expect(optOne.name).to.equal('foo'); + expect(optTwo.name).to.equal('foo'); + }); + + it('supports validation', async () => { + const el = await fixture(html` + <${tag} + id="color" + name="color" + label="Favorite color" + .validators="${[new Required()]}" + > + + <${optionTag} .choiceValue=${null}>select a color + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'hotpink'} disabled>Hotpink + <${optionTag} .choiceValue=${'teal'}>Teal + + + `); + + expect(el.hasFeedbackFor.includes('error')).to.be.true; + expect(el.showsFeedbackFor.includes('error')).to.be.false; + + // test submitted prop explicitly, since we dont extend field, we add the prop manually + el.submitted = true; + await el.updateComplete; + expect(el.showsFeedbackFor.includes('error')).to.be.true; + + el._listboxNode.children[1].checked = true; + await el.updateComplete; + expect(el.hasFeedbackFor.includes('error')).to.be.false; + expect(el.showsFeedbackFor.includes('error')).to.be.false; + + el._listboxNode.children[0].checked = true; + await el.updateComplete; + expect(el.hasFeedbackFor.includes('error')).to.be.true; + expect(el.showsFeedbackFor.includes('error')).to.be.true; + }); + + it('supports having no default selection initially', async () => { + const el = await fixture(html` + <${tag} id="color" name="color" label="Favorite color" has-no-default-selected> + + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'hotpink'}>Hotpink + <${optionTag} .choiceValue=${'teal'}>Teal + + + `); + + 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} .choiceValue=${'hotpink'}>Hotpink + <${optionTag} .choiceValue=${'teal'}>Teal + + + `); + + expect(el.checkedIndex).to.equal(0); + expect(el.serializedValue).to.equal('red'); + + el.serializedValue = 'hotpink'; + + expect(el.checkedIndex).to.equal(1); + expect(el.serializedValue).to.equal('hotpink'); + }); + + describe('Accessibility', () => { + it('is accessible when closed', async () => { + const el = await fixture(html` + <${tag} label="age"> + + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + + `); + await expect(el).to.be.accessible(); + }); + + it('is accessible when opened', async () => { + const el = await fixture(html` + <${tag} label="age"> + + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + + `); + el.opened = true; + await el.updateComplete; + await el.updateComplete; // need 2 awaits as overlay.show is an async function + + await expect(el).to.be.accessible(); + }); + }); + + describe('Use cases', () => { + it('works for complex array data', async () => { + const objs = [ + { type: 'mastercard', label: 'Master Card', amount: 12000, active: true }, + { type: 'visacard', label: 'Visa Card', amount: 0, active: false }, + ]; + const el = await fixture(html` + <${tag} label="Favorite color" name="color"> + + ${objs.map( + obj => html` + <${optionTag} .modelValue=${{ value: obj, checked: false }} + >${obj.label} + `, + )} + + + `); + expect(el.modelValue).to.deep.equal({ + type: 'mastercard', + label: 'Master Card', + amount: 12000, + active: true, + }); + + el.checkedIndex = 1; + expect(el.modelValue).to.deep.equal({ + type: 'visacard', + label: 'Visa Card', + amount: 0, + active: false, + }); + }); + }); + + describe('Instantiation methods', () => { + it('can be instantiated via "document.createElement"', async () => { + let properlyInstantiated = false; + + try { + const el = document.createElement('lion-listbox'); + const optionsEl = document.createElement('lion-options'); + optionsEl.slot = 'input'; + const optionEl = document.createElement('lion-option'); + optionsEl.appendChild(optionEl); + el.appendChild(optionsEl); + properlyInstantiated = true; + } catch (e) { + throw Error(e); + } + + expect(properlyInstantiated).to.be.true; + }); + }); + }); + + describe('lion-listbox interactions', () => { + describe('values', () => { + it('registers options', async () => { + const el = await fixture(html` + <${tag}> + + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + + 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} .choiceValue=${20}>Item 2 + + + `); + 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} .choiceValue=${20} checked>Item 2 + + + `); + expect(el.modelValue).to.equal(20); + }); + + it('has an activeIndex', async () => { + const el = await fixture(html` + <${tag}> + + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + + `); + 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 + + + `); + 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} .choiceValue=${'b'}>B + <${optionTag} .choiceValue=${'c'}>C + + + `); + 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} .choiceValue=${'far'}>Far + <${optionTag} .choiceValue=${'foo'}>Foo + + + `); + 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} .choiceValue=${20}>Item 2 + <${optionTag} .choiceValue=${30}>Item 3 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + + 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} .choiceValue=${20}>Item 2 + + + `); + 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} .choiceValue=${20} disabled>Item 2 + <${optionTag} .choiceValue=${30}>Item 3 + + + `); + 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} .choiceValue=${20}>Item 2 + <${optionTag} .choiceValue=${30} checked>Item 3 + <${optionTag} .choiceValue=${40} disabled>Item 4 + + + `); + 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} .choiceValue=${20}>Item 2 + <${optionTag} .choiceValue=${30}>Item 3 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + 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} .choiceValue=${20} id="myId">Item 2 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + 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 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + + 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 + + + `); + expect(el.prefilled).to.be.true; + + const elEmpty = await fixture(html` + <${tag}> + + <${optionTag} .choiceValue=${null}>Please select a value + <${optionTag} .choiceValue=${10}>Item 1 + + + `); + 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} .choiceValue=${20}>Item 2 + + + `); + + 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} .choiceValue=${20} selected>Item 2 + <${optionTag} .choiceValue=${30} id="predefined">Item 3 + + + `); + 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} .choiceValue=${20} checked id="second">Item 2 + + + `); + + 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} .choiceValue=${20}>Item 2 + <${optionTag} .choiceValue=${30}>Item 3 + + + `); + 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} .choiceValue=${20}>Item 2 + <${optionTag} .choiceValue=${30}>Item 3 + + + `); + const optionEls = [].slice.call(el.querySelectorAll('lion-option')); + optionEls.forEach((oEl, i) => { + expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`); + }); + }); + }); + }); +} diff --git a/packages/listbox/test/lion-listbox.test.js b/packages/listbox/test/lion-listbox.test.js new file mode 100644 index 000000000..93a95b01d --- /dev/null +++ b/packages/listbox/test/lion-listbox.test.js @@ -0,0 +1,3 @@ +import { runListboxMixinSuite } from '../test-suites/ListboxMixin.suite.js'; + +runListboxMixinSuite(); diff --git a/packages/select-rich/test/lion-option.test.js b/packages/listbox/test/lion-option.test.js similarity index 68% rename from packages/select-rich/test/lion-option.test.js rename to packages/listbox/test/lion-option.test.js index e049db09e..f6c8edb2d 100644 --- a/packages/select-rich/test/lion-option.test.js +++ b/packages/listbox/test/lion-option.test.js @@ -1,35 +1,43 @@ import { expect, fixture, html } from '@open-wc/testing'; import sinon from 'sinon'; +// eslint-disable-next-line no-unused-vars +import { LionOption } from '../src/LionOption.js'; import '../lion-option.js'; describe('lion-option', () => { describe('Values', () => { it('has a modelValue', async () => { - const el = await fixture(html``); + const el = /** @type {LionOption} */ (await fixture( + html``, + )); expect(el.modelValue).to.deep.equal({ value: 10, checked: false }); }); it('can be checked', async () => { - const el = await fixture(html``); + const el = /** @type {LionOption} */ (await fixture( + html``, + )); expect(el.modelValue).to.deep.equal({ value: 10, checked: true }); }); it('is hidden when attribute hidden is true', async () => { - const el = await fixture(html``); + const el = /** @type {LionOption} */ (await fixture( + html``, + )); expect(el).not.to.be.displayed; }); }); describe('Accessibility', () => { it('has the "option" role', async () => { - const el = await fixture(html``); + const el = /** @type {LionOption} */ (await fixture(html``)); expect(el.getAttribute('role')).to.equal('option'); }); it('has "aria-selected" attribute when checked', async () => { - const el = await fixture(html` + const el = /** @type {LionOption} */ (await fixture(html` Item 1 - `); + `)); expect(el.getAttribute('aria-selected')).to.equal('true'); el.checked = false; @@ -41,9 +49,9 @@ describe('lion-option', () => { }); it('asynchronously adds the attributes "aria-disabled" and "disabled" when disabled', async () => { - const el = await fixture(html` + const el = /** @type {LionOption} */ (await fixture(html` Item 1 - `); + `)); expect(el.getAttribute('aria-disabled')).to.equal('true'); expect(el.hasAttribute('disabled')).to.be.true; @@ -59,7 +67,9 @@ describe('lion-option', () => { describe('State reflection', () => { it('asynchronously adds the attribute "active" when active', async () => { - const el = await fixture(html``); + const el = /** @type {LionOption} */ (await fixture( + html``, + )); expect(el.active).to.equal(false); expect(el.hasAttribute('active')).to.be.false; @@ -77,7 +87,9 @@ describe('lion-option', () => { }); it('does become checked on [click]', async () => { - const el = await fixture(html``); + const el = /** @type {LionOption} */ (await fixture( + html``, + )); expect(el.checked).to.be.false; el.click(); await el.updateComplete; @@ -86,9 +98,12 @@ describe('lion-option', () => { it('fires active-changed event', async () => { const activeSpy = sinon.spy(); - const el = await fixture(html` - - `); + const el = /** @type {LionOption} */ (await fixture(html` + + `)); expect(activeSpy.callCount).to.equal(0); el.active = true; expect(activeSpy.callCount).to.equal(1); @@ -97,14 +112,18 @@ describe('lion-option', () => { describe('Disabled', () => { it('does not becomes active on [mouseenter]', async () => { - const el = await fixture(html``); + const el = /** @type {LionOption} */ (await fixture( + html``, + )); expect(el.active).to.be.false; el.dispatchEvent(new Event('mouseenter')); expect(el.active).to.be.false; }); it('does not become checked on [click]', async () => { - const el = await fixture(html``); + const el = /** @type {LionOption} */ (await fixture( + html``, + )); expect(el.checked).to.be.false; el.click(); await el.updateComplete; @@ -112,9 +131,9 @@ describe('lion-option', () => { }); it('does not become un-active on [mouseleave]', async () => { - const el = await fixture(html` + const el = /** @type {LionOption} */ (await fixture(html` - `); + `)); expect(el.active).to.be.true; el.dispatchEvent(new Event('mouseleave')); expect(el.active).to.be.true; diff --git a/packages/select-rich/test/lion-options.test.js b/packages/listbox/test/lion-options.test.js similarity index 68% rename from packages/select-rich/test/lion-options.test.js rename to packages/listbox/test/lion-options.test.js index b3793a749..fb4d16e34 100644 --- a/packages/select-rich/test/lion-options.test.js +++ b/packages/listbox/test/lion-options.test.js @@ -1,13 +1,14 @@ import { expect, fixture, html } from '@open-wc/testing'; - +// eslint-disable-next-line no-unused-vars +import { LionOptions } from '../src/LionOptions.js'; import '../lion-options.js'; describe('lion-options', () => { it('should have role="listbox"', async () => { const registrationTargetEl = document.createElement('div'); - const el = await fixture(html` + const el = /** @type {LionOptions} */ (await fixture(html` - `); + `)); expect(el.role).to.equal('listbox'); }); }); diff --git a/packages/listbox/types/LionOption.d.ts b/packages/listbox/types/LionOption.d.ts new file mode 100644 index 000000000..428930cd3 --- /dev/null +++ b/packages/listbox/types/LionOption.d.ts @@ -0,0 +1,5 @@ +import { ChoiceGroupHost } from '@lion/form-core/types/choice-group/ChoiceGroupMixinTypes'; + +export declare class LionOptionHost { + private __parentFormGroup: ChoiceGroupHost; +} diff --git a/packages/listbox/types/ListboxMixinTypes.d.ts b/packages/listbox/types/ListboxMixinTypes.d.ts new file mode 100644 index 000000000..4907e8740 --- /dev/null +++ b/packages/listbox/types/ListboxMixinTypes.d.ts @@ -0,0 +1,83 @@ +import { Constructor } from '@open-wc/dedupe-mixin'; +import { LitElement } from '@lion/core'; +import { SlotHost } from '@lion/core/types/SlotMixinTypes'; + +import { FormControlHost } from '@lion/form-core/types/FormControlMixinTypes'; +import { FormRegistrarHost } from '@lion/form-core/types/registration/FormRegistrarMixinTypes'; +import { ChoiceGroupHost } from '@lion/form-core/types/choice-group/ChoiceGroupMixinTypes'; +import { LionOptions } from '../src/LionOptions.js'; +import { LionOption } from '../src/LionOption.js'; + +export declare class ListboxHost { + /** + * When true, will synchronize activedescendant and selected element on + * arrow key navigation. + * This behavior can usually be seen on