From 66b7880a9e237a95f7445e371e8ce482ec2e054d Mon Sep 17 00:00:00 2001 From: Thomas Allmer Date: Thu, 25 Jul 2019 16:19:48 +0200 Subject: [PATCH] feat: add lion-select-rich --- packages/option/README.md | 49 ++ packages/option/index.js | 1 + packages/option/lion-option.js | 3 + packages/option/package.json | 44 ++ packages/option/src/LionOption.js | 115 +++ packages/option/stories/index.stories.js | 35 + packages/option/test/lion-option.test.js | 151 ++++ packages/select-rich/README.md | 79 +++ packages/select-rich/lion-options.js | 3 + packages/select-rich/lion-select-invoker.js | 3 + packages/select-rich/lion-select-rich.js | 3 + packages/select-rich/package.json | 51 ++ packages/select-rich/src/LionOptions.js | 35 + packages/select-rich/src/LionSelectInvoker.js | 64 ++ packages/select-rich/src/LionSelectRich.js | 607 ++++++++++++++++ .../src/differentKeyNamesShimIE.js | 33 + packages/select-rich/stories/index.stories.js | 183 +++++ .../select-rich/test/keyboardEventShimIE.js | 49 ++ .../select-rich/test/lion-options.test.js | 12 + .../test/lion-select-invoker.test.js | 76 ++ .../test/lion-select-rich-interaction.test.js | 657 ++++++++++++++++++ .../select-rich/test/lion-select-rich.test.js | 321 +++++++++ stories/index.stories.js | 3 + 23 files changed, 2577 insertions(+) create mode 100644 packages/option/README.md create mode 100644 packages/option/index.js create mode 100644 packages/option/lion-option.js create mode 100644 packages/option/package.json create mode 100644 packages/option/src/LionOption.js create mode 100644 packages/option/stories/index.stories.js create mode 100644 packages/option/test/lion-option.test.js create mode 100644 packages/select-rich/README.md create mode 100644 packages/select-rich/lion-options.js create mode 100644 packages/select-rich/lion-select-invoker.js create mode 100644 packages/select-rich/lion-select-rich.js create mode 100644 packages/select-rich/package.json create mode 100644 packages/select-rich/src/LionOptions.js create mode 100644 packages/select-rich/src/LionSelectInvoker.js create mode 100644 packages/select-rich/src/LionSelectRich.js create mode 100644 packages/select-rich/src/differentKeyNamesShimIE.js create mode 100644 packages/select-rich/stories/index.stories.js create mode 100644 packages/select-rich/test/keyboardEventShimIE.js create mode 100644 packages/select-rich/test/lion-options.test.js create mode 100644 packages/select-rich/test/lion-select-invoker.test.js create mode 100644 packages/select-rich/test/lion-select-rich-interaction.test.js create mode 100644 packages/select-rich/test/lion-select-rich.test.js diff --git a/packages/option/README.md b/packages/option/README.md new file mode 100644 index 000000000..2439420bb --- /dev/null +++ b/packages/option/README.md @@ -0,0 +1,49 @@ +# LionOption + +[//]: # 'AUTO INSERT HEADER PREPUBLISH' + +`lion-option` is a selectable within a [lion-select-rich](../select-rich/) + +## Features + +- has checked state +- has a modelValue +- can be disabled +- fully accessible + +## How to use + +### Installation + +```sh +npm i --save @lion/select-rich +``` + +```js +import '@lion/select-rich/lion-select-rich.js'; +import '@lion/select-rich/lion-options.js'; +import '@lion/option/lion-option.js'; +``` + +### Example + +```html + + + Red + Hotpink + + +``` + +You can also set the full modelValue for each option. + +```html +Red +``` + +For more details please see [lion-select-rich](../select-rich/). diff --git a/packages/option/index.js b/packages/option/index.js new file mode 100644 index 000000000..5a42f38df --- /dev/null +++ b/packages/option/index.js @@ -0,0 +1 @@ +export { LionOption } from './src/LionOption.js'; diff --git a/packages/option/lion-option.js b/packages/option/lion-option.js new file mode 100644 index 000000000..47a8282f3 --- /dev/null +++ b/packages/option/lion-option.js @@ -0,0 +1,3 @@ +import { LionOption } from './src/LionOption.js'; + +customElements.define('lion-option', LionOption); diff --git a/packages/option/package.json b/packages/option/package.json new file mode 100644 index 000000000..5f7e1057a --- /dev/null +++ b/packages/option/package.json @@ -0,0 +1,44 @@ +{ + "name": "@lion/option", + "version": "0.0.0", + "description": "Allows to provide options for a rich select", + "author": "ing-bank", + "homepage": "https://github.com/ing-bank/lion/", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/ing-bank/lion.git", + "directory": "packages/option" + }, + "scripts": { + "prepublishOnly": "../../scripts/npm-prepublish.js" + }, + "keywords": [ + "lion", + "web-components", + "option" + ], + "main": "index.js", + "module": "index.js", + "files": [ + "docs", + "src", + "stories", + "test", + "translations", + "*.js" + ], + "dependencies": { + "@lion/core": "^0.1.13", + "@lion/field": "^0.1.38", + "@lion/choice-input": "^0.2.18" + }, + "devDependencies": { + "@open-wc/demoing-storybook": "^0.2.0", + "@open-wc/testing": "^2.0.6", + "sinon": "^7.2.2" + } +} diff --git a/packages/option/src/LionOption.js b/packages/option/src/LionOption.js new file mode 100644 index 000000000..4d299b181 --- /dev/null +++ b/packages/option/src/LionOption.js @@ -0,0 +1,115 @@ +import { html, css, LitElement, DisabledMixin } from '@lion/core'; +import { FormRegisteringMixin } from '@lion/field'; +import { ChoiceInputMixin } from '@lion/choice-input'; + +/** + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option + * Can be a child of datalist/select, or role="listbox" + * + * Element gets state supplied externally, reflects this to attributes, + * enabling SubClassers to style based on those states + */ +export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMixin(LitElement))) { + static get properties() { + return { + active: { + type: Boolean, + reflect: true, + }, + }; + } + + static get styles() { + return [ + css` + :host { + display: block; + background-color: white; + padding: 4px; + } + + :host([active]) { + background-color: #ddd; + } + + :host([checked]) { + background-color: #bde4ff; + } + + :host([disabled]) { + color: #adadad; + } + `, + ]; + } + + constructor() { + super(); + this.active = false; + this.__registerEventListener(); + } + + _requestUpdate(name, oldValue) { + super._requestUpdate(name, oldValue); + + if (name === 'active') { + this.dispatchEvent(new Event('active-changed', { bubbles: true })); + } + } + + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has('checked')) { + this.setAttribute('aria-selected', `${this.checked}`); + } + + if (changedProperties.has('disabled')) { + this.setAttribute('aria-disabled', `${this.disabled}`); + } + } + + render() { + return html` +
+ +
+ `; + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'option'); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.__unRegisterEventListeners(); + } + + __registerEventListener() { + this.__onClick = () => { + if (!this.disabled) { + this.checked = true; + } + }; + this.__onMouseEnter = () => { + if (!this.disabled) { + this.active = true; + } + }; + this.__onMouseLeave = () => { + if (!this.disabled) { + this.active = false; + } + }; + this.addEventListener('click', this.__onClick); + this.addEventListener('mouseenter', this.__onMouseEnter); + this.addEventListener('mouseleave', this.__onMouseLeave); + } + + __unRegisterEventListeners() { + this.removeEventListener('click', this.__onClick); + this.removeEventListener('mouseenter', this.__onMouseEnter); + this.removeEventListener('mouseleave', this.__onMouseLeave); + } +} diff --git a/packages/option/stories/index.stories.js b/packages/option/stories/index.stories.js new file mode 100644 index 000000000..2df9de6ab --- /dev/null +++ b/packages/option/stories/index.stories.js @@ -0,0 +1,35 @@ +import { storiesOf, html } from '@open-wc/demoing-storybook'; + +import '../lion-option.js'; + +storiesOf('Forms|Option', module) + .add( + 'States', + () => html` + Default
+ Disabled + +

With html

+

and multi Line

+
+ `, + ) + .add( + 'Values', + () => html` + setting modelValue + setting modelValue active + setting modelValue checked + setting modelValue disabled + setting choiceValue + setting choiceValue active + setting choiceValue checked + setting choiceValue disabled + `, + ); diff --git a/packages/option/test/lion-option.test.js b/packages/option/test/lion-option.test.js new file mode 100644 index 000000000..e034c586c --- /dev/null +++ b/packages/option/test/lion-option.test.js @@ -0,0 +1,151 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import sinon from 'sinon'; + +import '../lion-option.js'; + +describe('lion-option', () => { + describe('Values', () => { + it('has a modelValue', async () => { + const el = await fixture(html` + + `); + expect(el.modelValue).to.deep.equal({ value: 10, checked: false }); + }); + + it('can be checked', async () => { + const el = await fixture(html` + + `); + expect(el.modelValue).to.deep.equal({ value: 10, checked: true }); + }); + }); + + describe('Accessibility', () => { + it('has the "option" role', async () => { + const el = await fixture(html` + + `); + expect(el.getAttribute('role')).to.equal('option'); + }); + + it('has "aria-selected" attribute when checked', async () => { + const el = await fixture(html` + Item 1 + `); + expect(el.getAttribute('aria-selected')).to.equal('true'); + + el.checked = false; + // check that dom update is async + expect(el.getAttribute('aria-selected')).to.equal('true'); + + await el.updateComplete; + expect(el.getAttribute('aria-selected')).to.equal('false'); + }); + + it('asynchronously adds the attributes "aria-disabled" and "disabled" when disabled', async () => { + const el = await fixture(html` + Item 1 + `); + expect(el.getAttribute('aria-disabled')).to.equal('true'); + expect(el.hasAttribute('disabled')).to.be.true; + + el.disabled = false; + expect(el.getAttribute('aria-disabled')).to.equal('true'); + expect(el.hasAttribute('disabled')).to.be.true; + + await el.updateComplete; + expect(el.getAttribute('aria-disabled')).to.equal('false'); + expect(el.hasAttribute('disabled')).to.be.false; + }); + }); + + describe('State reflection', () => { + it('asynchronously adds the attribute "active" when active', async () => { + const el = await fixture(html` + + `); + expect(el.active).to.equal(false); + expect(el.hasAttribute('active')).to.be.false; + + el.active = true; + expect(el.active).to.be.true; + expect(el.hasAttribute('active')).to.be.false; + await el.updateComplete; + expect(el.hasAttribute('active')).to.be.true; + + el.active = false; + expect(el.active).to.be.false; + expect(el.hasAttribute('active')).to.be.true; + await el.updateComplete; + expect(el.hasAttribute('active')).to.be.false; + }); + + it('does become active on [mouseenter]', async () => { + const el = await fixture(html` + + `); + expect(el.active).to.be.false; + el.dispatchEvent(new Event('mouseenter')); + expect(el.active).to.be.true; + }); + + it('does become un-active on [mouseleave]', async () => { + const el = await fixture(html` + + `); + expect(el.active).to.be.true; + el.dispatchEvent(new Event('mouseleave')); + expect(el.active).to.be.false; + }); + + it('does become checked on [click]', async () => { + const el = await fixture(html` + + `); + expect(el.checked).to.be.false; + el.click(); + await el.updateComplete; + expect(el.checked).to.be.true; + }); + + it('fires active-changed event', async () => { + const activeSpy = sinon.spy(); + const el = await fixture(html` + + `); + expect(activeSpy.callCount).to.equal(0); + el.active = true; + expect(activeSpy.callCount).to.equal(1); + }); + }); + + describe('Disabled', () => { + it('does not becomes active on [mouseenter]', async () => { + const el = 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` + + `); + expect(el.checked).to.be.false; + el.click(); + await el.updateComplete; + expect(el.checked).to.be.false; + }); + + it('does not become un-active on [mouseleave]', async () => { + const el = 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/README.md b/packages/select-rich/README.md new file mode 100644 index 000000000..4c444798b --- /dev/null +++ b/packages/select-rich/README.md @@ -0,0 +1,79 @@ +# Select Rich + +[//]: # 'AUTO INSERT HEADER PREPUBLISH' + +`lion-select-rich` component is a 'rich' version of the native `` doesn't provide enough +styling/theming/user interaction opportunities. + +Its implementation is based on the following Design pattern: + + +## Features + +- fully accessible +- flexible api +- fully customizable option elements +- fully customizable invoker element +- Mimics native select interaction mode (windows/linux and mac) + +## How to use + +### Installation + +```sh +npm i --save @lion/select-rich +``` + +```js +import '@lion/select-rich/lion-select-rich.js'; +import '@lion/select-rich/lion-options.js'; +import '@lion/option/lion-option.js'; +``` + +### Example + +```html + + + Red + Hotpink + + +``` + +You can also set the full modelValue for each option. + +```html + Red +``` + +You can get/set the the checkedIndex and checkedValue + +```js +const el = document.querySelector('lion-select-rich'); +console.log(el.checkedIndex); // 1 +console.log(el.checkedValue); // 'hotpink' +console.log(el.modelValue); // [{ value: 'red', checked: false }, { value: 'hotpink', checked: true }] +``` + +You can provide an invoker rendering a custom invoker that gets the selected value(s) as an +input property `.selectedElement` + +```html + + + + ... + + +``` + +## Other Resources + +- [Design Considerations](./docs/DesignConsiderations.md) diff --git a/packages/select-rich/lion-options.js b/packages/select-rich/lion-options.js new file mode 100644 index 000000000..b7a91f95a --- /dev/null +++ b/packages/select-rich/lion-options.js @@ -0,0 +1,3 @@ +import { LionOptions } from './src/LionOptions.js'; + +customElements.define('lion-options', LionOptions); diff --git a/packages/select-rich/lion-select-invoker.js b/packages/select-rich/lion-select-invoker.js new file mode 100644 index 000000000..9edbf0c49 --- /dev/null +++ b/packages/select-rich/lion-select-invoker.js @@ -0,0 +1,3 @@ +import { LionSelectInvoker } from './src/LionSelectInvoker.js'; + +customElements.define('lion-select-invoker', LionSelectInvoker); diff --git a/packages/select-rich/lion-select-rich.js b/packages/select-rich/lion-select-rich.js new file mode 100644 index 000000000..13349a50c --- /dev/null +++ b/packages/select-rich/lion-select-rich.js @@ -0,0 +1,3 @@ +import { LionSelectRich } from './src/LionSelectRich.js'; + +customElements.define('lion-select-rich', LionSelectRich); diff --git a/packages/select-rich/package.json b/packages/select-rich/package.json new file mode 100644 index 000000000..2f021e109 --- /dev/null +++ b/packages/select-rich/package.json @@ -0,0 +1,51 @@ +{ + "name": "@lion/select-rich", + "version": "0.0.0", + "description": "Provides a select with options that can contain html", + "author": "ing-bank", + "homepage": "https://github.com/ing-bank/lion/", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/ing-bank/lion.git", + "directory": "packages/select-rich" + }, + "scripts": { + "prepublishOnly": "../../scripts/npm-prepublish.js" + }, + "keywords": [ + "lion", + "web-components", + "select", + "listbox", + "field", + "form", + "option" + ], + "main": "index.js", + "module": "index.js", + "files": [ + "docs", + "src", + "stories", + "test", + "translations", + "*.js" + ], + "dependencies": { + "@lion/core": "^0.1.13", + "@lion/overlays": "^0.3.11", + "@lion/button": "^0.2.0", + "@lion/option": "^0.0.0", + "@lion/validate": "^0.2.22", + "@lion/field": "^0.1.38" + }, + "devDependencies": { + "@open-wc/demoing-storybook": "^0.2.0", + "@open-wc/testing": "^2.0.6", + "@lion/form": "^0.1.21" + } +} diff --git a/packages/select-rich/src/LionOptions.js b/packages/select-rich/src/LionOptions.js new file mode 100644 index 000000000..b78a4fced --- /dev/null +++ b/packages/select-rich/src/LionOptions.js @@ -0,0 +1,35 @@ +import { LitElement } from '@lion/core'; + +/** + * LionOptions + * + * @customElement + * @extends LitElement + */ +export class LionOptions extends LitElement { + static get properties() { + return { + role: { + type: String, + reflect: true, + }, + tabIndex: { + type: Number, + reflect: true, + attribute: 'tabindex', + }, + }; + } + + 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; + } + + createRenderRoot() { + return this; + } +} diff --git a/packages/select-rich/src/LionSelectInvoker.js b/packages/select-rich/src/LionSelectInvoker.js new file mode 100644 index 000000000..063f5712d --- /dev/null +++ b/packages/select-rich/src/LionSelectInvoker.js @@ -0,0 +1,64 @@ +import { LionButton } from '@lion/button'; +import { html } from '@lion/core'; + +/** + * LionSelectInvoker: invoker button consuming a selected element + * + * @customElement + * @extends LionButton + */ +export class LionSelectInvoker extends LionButton { + static get properties() { + return { + selectedElement: { + type: Object, + }, + }; + } + + get slots() { + return { + ...super.slots, + after: () => { + const icon = document.createElement('span'); + icon.textContent = '▼'; + return icon; + }, + }; + } + + get contentWrapper() { + return this.shadowRoot.getElementById('content-wrapper'); + } + + constructor() { + super(); + this.selectedElement = null; + } + + _contentTemplate() { + if (this.selectedElement) { + const labelNodes = Array.from(this.selectedElement.querySelectorAll('*')); + if (labelNodes.length > 0) { + return labelNodes.map(node => node.cloneNode(true)); + } + return this.selectedElement.textContent; + } + return ``; + } + + _renderBefore() { + return html` +
+ ${this._contentTemplate()} +
+ `; + } + + // eslint-disable-next-line class-methods-use-this + _renderAfter() { + return html` + + `; + } +} diff --git a/packages/select-rich/src/LionSelectRich.js b/packages/select-rich/src/LionSelectRich.js new file mode 100644 index 000000000..e888264d2 --- /dev/null +++ b/packages/select-rich/src/LionSelectRich.js @@ -0,0 +1,607 @@ +import { html, css, LitElement, SlotMixin } from '@lion/core'; +import { LocalOverlayController, overlays } from '@lion/overlays'; +import { FormControlMixin, InteractionStateMixin, FormRegistrarMixin } from '@lion/field'; +import { ValidateMixin } from '@lion/validate'; +import './differentKeyNamesShimIE.js'; + +import '../lion-select-invoker.js'; + +function uuid() { + return Math.random() + .toString(36) + .substr(2, 10); +} + +function detectInteractionMode() { + if (navigator.appVersion.indexOf('Mac') !== -1) { + return 'mac'; + } + return 'windows/linux'; +} + +/** + * LionSelectRich: wraps the element + * + * @customElement + * @extends LionField + */ +export class LionSelectRich extends FormRegistrarMixin( + InteractionStateMixin(ValidateMixin(FormControlMixin(SlotMixin(LitElement)))), +) { + static get properties() { + return { + ...super.properties, + checkedValue: { + type: Object, + }, + + disabled: { + type: Boolean, + reflect: true, + }, + + opened: { + type: Boolean, + reflect: true, + }, + + interactionMode: { + type: String, + attribute: 'interaction-mode', + }, + + modelValue: { + type: Array, + }, + + name: { + type: String, + }, + }; + } + + static get styles() { + return [ + css` + :host { + display: block; + } + + :host([disabled]) { + color: #adadad; + } + `, + ]; + } + + static _isPrefilled(modelValue) { + if (!modelValue) { + return false; + } + const checkedModelValue = modelValue.find(subModelValue => subModelValue.checked === true); + if (!checkedModelValue) { + return false; + } + + const { value } = checkedModelValue; + return super._isPrefilled(value); + } + + get slots() { + return { + ...super.slots, + invoker: () => { + return document.createElement('lion-select-invoker'); + }, + }; + } + + get _invokerNode() { + return this.querySelector('[slot=invoker]'); + } + + get _listboxNode() { + return this.querySelector('[slot=input]'); + } + + get _listboxActiveDescendantNode() { + return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`); + } + + get checkedIndex() { + if (this.modelValue) { + return this.modelValue.findIndex(el => el.value === this.checkedValue); + } + return -1; + } + + set checkedIndex(index) { + if (this.formElements[index]) { + this.formElements[index].checked = true; + } + } + + get activeIndex() { + return this.formElements.findIndex(el => el.active === true); + } + + set activeIndex(index) { + if (this.formElements[index]) { + this.formElements[index].active = true; + } + } + + constructor() { + super(); + this.interactionMode = 'auto'; + this.disabled = false; + this.opened = false; + // for interaction states + // we use a different event as 'model-value-changed' would bubble up from all options + this._valueChangedEvent = 'select-model-value-changed'; + this._listboxActiveDescendant = null; + this.__hasInitialSelectedFormElement = false; + + this.__setupEventListeners(); + } + + connectedCallback() { + if (super.connectedCallback) { + super.connectedCallback(); + } + + this.__setupOverlay(); + this.__setupInvokerNode(); + this.__setupListboxNode(); + } + + disconnectedCallback() { + if (super.disconnectedCallback) { + super.disconnectedCallback(); + } + this.__teardownEventListeners(); + this.__teardownOverlay(); + this.__teardownInvokerNode(); + this.__teardownListboxNode(); + } + + _requestUpdate(name, oldValue) { + super._requestUpdate(name, oldValue); + if ( + name === 'checkedValue' && + !this.__isSyncingCheckedAndModelValue && + this.modelValue && + this.modelValue.length > 0 + ) { + if (this.checkedIndex) { + this.checkedIndex = this.checkedIndex; + } + } + + if (name === 'modelValue') { + this.dispatchEvent(new CustomEvent('select-model-value-changed')); + this.__onModelValueChanged(); + } + + if (name === 'interactionMode') { + if (this.interactionMode === 'auto') { + this.interactionMode = detectInteractionMode(); + } + } + } + + updated(changedProps) { + super.updated(changedProps); + if (changedProps.has('opened')) { + if (this.opened) { + this.__overlay.show(); + } else { + this.__overlay.hide(); + } + } + + if (changedProps.has('disabled')) { + if (this.disabled) { + this._invokerNode.makeRequestToBeDisabled(); + this.__requestOptionsToBeDisabled(); + } else { + this._invokerNode.retractRequestToBeDisabled(); + this.__retractRequestOptionsToBeDisabled(); + } + } + } + + toggle() { + this.opened = !this.opened; + } + + /** + * @override + */ + // eslint-disable-next-line + inputGroupInputTemplate() { + return html` +
+ + +
+ `; + } + + /** + * Overrides FormRegistrar adding to make sure children have specific default states when added + * + * @override + * @param {*} child + */ + addFormElement(child) { + super.addFormElement(child); + // 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.__hasInitialSelectedFormElement && (!child.disabled || this.disabled)) { + child.active = true; + child.checked = true; + this.__hasInitialSelectedFormElement = true; + } + + this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length); + child.setAttribute('aria-posinset', this.formElements.length); + + this.__onChildModelValueChanged({ target: child }); + this.resetInteractionState(); + /* eslint-enable no-param-reassign */ + } + + _getFromAllFormElements(property) { + return this.formElements.map(e => e[property]); + } + + /** + * add same aria-label to invokerNode as inputElement + * @override + */ + _onAriaLabelledbyChanged({ _ariaLabelledby }) { + if (this.inputElement) { + this.inputElement.setAttribute('aria-labelledby', _ariaLabelledby); + } + if (this._invokerNode) { + this._invokerNode.setAttribute( + 'aria-labelledby', + `${_ariaLabelledby} ${this._invokerNode.id}`, + ); + } + } + + /** + * add same aria-label to invokerNode as inputElement + * @override + */ + _onAriaDescribedbyChanged({ _ariaDescribedby }) { + if (this.inputElement) { + this.inputElement.setAttribute('aria-describedby', _ariaDescribedby); + } + if (this._invokerNode) { + this._invokerNode.setAttribute('aria-describedby', _ariaDescribedby); + } + } + + __setupEventListeners() { + this.__onChildActiveChanged = this.__onChildActiveChanged.bind(this); + this.__onChildModelValueChanged = this.__onChildModelValueChanged.bind(this); + this.__onKeyUp = this.__onKeyUp.bind(this); + + this.addEventListener('active-changed', this.__onChildActiveChanged); + this.addEventListener('model-value-changed', this.__onChildModelValueChanged); + this.addEventListener('keyup', this.__onKeyUp); + } + + __teardownEventListeners() { + this.removeEventListener('active-changed', this.__onChildActiveChanged); + this.removeEventListener('model-value-changed', this.__onChildModelValueChanged); + this.removeEventListener('keyup', this.__onKeyUp); + } + + __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); + } + } + + __setAttributeForAllFormElements(attribute, value) { + this.formElements.forEach(formElement => { + formElement.setAttribute(attribute, value); + }); + } + + __onChildModelValueChanged({ target }) { + if (target.checked) { + this.formElements.forEach(formElement => { + if (formElement !== target) { + // eslint-disable-next-line no-param-reassign + formElement.checked = false; + } + }); + } + this.modelValue = this._getFromAllFormElements('modelValue'); + } + + __onModelValueChanged() { + this.__isSyncingCheckedAndModelValue = true; + + const foundChecked = this.modelValue.find(subModelValue => subModelValue.checked); + if (foundChecked && foundChecked.value !== this.checkedValue) { + this.checkedValue = foundChecked.value; + + // sync to invoker + this._invokerNode.selectedElement = this.formElements[this.checkedIndex]; + } + + this.__isSyncingCheckedAndModelValue = false; + } + + __getNextEnabledOption(currentIndex, offset = 1) { + for (let i = currentIndex + offset; i < this.formElements.length; i += 1) { + if (this.formElements[i] && !this.formElements[i].disabled) { + return i; + } + } + return currentIndex; + } + + __getPreviousEnabledOption(currentIndex, offset = -1) { + for (let i = currentIndex + offset; i >= 0; i -= 1) { + if (this.formElements[i] && !this.formElements[i].disabled) { + return i; + } + } + return currentIndex; + } + + /** + * @desc + * Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects + * an item. + * + * @param ev - the keydown event object + */ + __listboxOnKeyUp(ev) { + if (this.disabled) { + return; + } + + const { key } = ev; + + switch (key) { + case 'Escape': + ev.preventDefault(); + this.opened = false; + break; + case 'Enter': + case ' ': + ev.preventDefault(); + if (this.interactionMode === 'mac') { + this.checkedIndex = this.activeIndex; + } + this.opened = false; + break; + case 'ArrowUp': + ev.preventDefault(); + this.activeIndex = this.__getPreviousEnabledOption(this.activeIndex); + break; + case 'ArrowDown': + ev.preventDefault(); + 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.interactionMode === 'windows/linux') { + this.checkedIndex = this.activeIndex; + } + } + + __listboxOnKeyDown(ev) { + if (this.disabled) { + return; + } + + const { key } = ev; + + switch (key) { + case 'Tab': + // Tab can only be caught in keydown + ev.preventDefault(); + this.opened = false; + break; + /* no default */ + } + } + + __onKeyUp(ev) { + if (this.disabled) { + return; + } + + if (this.opened) { + return; + } + + const { key } = ev; + switch (key) { + case 'ArrowUp': + ev.preventDefault(); + if (this.interactionMode === 'mac') { + this.opened = true; + } else { + this.checkedIndex = this.__getPreviousEnabledOption(this.checkedIndex); + } + break; + case 'ArrowDown': + ev.preventDefault(); + if (this.interactionMode === 'mac') { + this.opened = true; + } else { + this.checkedIndex = this.__getNextEnabledOption(this.checkedIndex); + } + break; + /* no default */ + } + } + + __requestOptionsToBeDisabled() { + this.formElements.forEach(el => { + if (el.makeRequestToBeDisabled) { + el.makeRequestToBeDisabled(); + } + }); + } + + __retractRequestOptionsToBeDisabled() { + this.formElements.forEach(el => { + if (el.retractRequestToBeDisabled) { + el.retractRequestToBeDisabled(); + } + }); + } + + __setupInvokerNode() { + this._invokerNode.id = `invoker-${this._inputId}`; + this._invokerNode.setAttribute('aria-haspopup', 'listbox'); + + this.__setupInvokerNodeEventListener(); + } + + __setupInvokerNodeEventListener() { + this.__invokerOnClick = () => { + if (!this.disabled) { + this.toggle(); + } + }; + this._invokerNode.addEventListener('click', this.__invokerOnClick); + + this.__invokerOnBlur = () => { + this.dispatchEvent(new Event('blur')); + }; + this._invokerNode.addEventListener('blur', this.__invokerOnBlur); + } + + __teardownInvokerNode() { + this._invokerNode.removeEventListener('click', this.__invokerOnClick); + this._invokerNode.removeEventListener('blur', this.__invokerOnBlur); + } + + /** + * 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.__setupListboxNodeEventListener(); + } else { + const inputSlot = this.shadowRoot.querySelector('slot[name=input]'); + if (inputSlot) { + inputSlot.addEventListener('slotchange', () => { + this.__setupListboxNodeEventListener(); + }); + } + } + } + + __setupListboxNodeEventListener() { + this.__listboxOnClick = () => { + this.opened = false; + }; + this._listboxNode.addEventListener('click', this.__listboxOnClick); + + this.__listboxOnKeyUp = this.__listboxOnKeyUp.bind(this); + this._listboxNode.addEventListener('keyup', this.__listboxOnKeyUp); + + this.__listboxOnKeyDown = this.__listboxOnKeyDown.bind(this); + this._listboxNode.addEventListener('keydown', this.__listboxOnKeyDown); + } + + __teardownListboxNode() { + if (this._listboxNode) { + this._listboxNode.removeEventListener('click', this.__listboxOnClick); + this._listboxNode.removeEventListener('keyup', this.__listboxOnKeyUp); + this._listboxNode.removeEventListener('keydown', this.__listboxOnKeyDown); + } + } + + __setupOverlay() { + this.__overlay = overlays.add( + new LocalOverlayController({ + contentNode: this._listboxNode, + invokerNode: this._invokerNode, + hidesOnEsc: false, + hidesOnOutsideClick: true, + inheritsReferenceObjectWidth: true, + popperConfig: { + placement: 'bottom-start', + modifiers: { + offset: { + enabled: false, + }, + }, + }, + }), + ); + + this.__overlayOnShow = () => { + this.opened = true; + if (this.checkedIndex) { + this.activeIndex = this.checkedIndex; + } + this._listboxNode.focus(); + }; + this.__overlay.addEventListener('show', this.__overlayOnShow); + + this.__overlayOnHide = () => { + this.opened = false; + this._invokerNode.focus(); + }; + this.__overlay.addEventListener('hide', this.__overlayOnHide); + } + + __teardownOverlay() { + this.__overlay.removeEventListener('show', this.__overlayOnShow); + this.__overlay.removeEventListener('hide', this.__overlayOnHide); + } + + // eslint-disable-next-line class-methods-use-this + __isRequired(modelValue) { + const checkedModelValue = modelValue.find(subModelValue => subModelValue.checked === true); + if (!checkedModelValue) { + return { required: false }; + } + const { value } = checkedModelValue; + return { + required: + (typeof value === 'string' && value !== '') || + (typeof value !== 'string' && value !== undefined && value !== null), + }; + } +} diff --git a/packages/select-rich/src/differentKeyNamesShimIE.js b/packages/select-rich/src/differentKeyNamesShimIE.js new file mode 100644 index 000000000..65b6d49d1 --- /dev/null +++ b/packages/select-rich/src/differentKeyNamesShimIE.js @@ -0,0 +1,33 @@ +const event = KeyboardEvent.prototype; +const descriptor = Object.getOwnPropertyDescriptor(event, 'key'); +if (descriptor) { + const keys = { + Win: 'Meta', + Scroll: 'ScrollLock', + Spacebar: ' ', + + Down: 'ArrowDown', + Left: 'ArrowLeft', + Right: 'ArrowRight', + Up: 'ArrowUp', + + Del: 'Delete', + Apps: 'ContextMenu', + Esc: 'Escape', + + Multiply: '*', + Add: '+', + Subtract: '-', + Decimal: '.', + Divide: '/', + }; + Object.defineProperty(event, 'key', { + // eslint-disable-next-line object-shorthand, func-names + get: function() { + const key = descriptor.get.call(this); + + // eslint-disable-next-line no-prototype-builtins + return keys.hasOwnProperty(key) ? keys[key] : key; + }, + }); +} diff --git a/packages/select-rich/stories/index.stories.js b/packages/select-rich/stories/index.stories.js new file mode 100644 index 000000000..1ade3e10e --- /dev/null +++ b/packages/select-rich/stories/index.stories.js @@ -0,0 +1,183 @@ +import { storiesOf, html } from '@open-wc/demoing-storybook'; +import { css } from '@lion/core'; + +import '@lion/form/lion-form.js'; +import '@lion/option/lion-option.js'; + +import '../lion-select-rich.js'; +import '../lion-options.js'; + +const selectRichDemoStyle = css` + .demo-area { + margin: 50px; + } +`; + +storiesOf('Forms|Select Rich', module) + .add( + 'Default', + () => html` + +
+ + + Red + Hotpink + Teal + + +
+ `, + ) + .add( + 'Options with HTML', + () => html` + +
+ + + +

I am red

+

and multi Line

+
+ +

I am hotpink

+

and multi Line

+
+ +

I am teal

+

and multi Line

+
+
+
+
+ `, + ) + .add( + 'Disabled', + () => html` + +
+ + + Red + Hotpink + Teal + + + + + + Red + Blue + Hotpink + Green + Teal + + +
+ `, + ) + .add('Validation', () => { + const submit = () => { + const form = document.querySelector('#form'); + if (form.errorState === false) { + console.log(form.serializeGroup()); + } + }; + return html` + +
+ +
+ + + select a color + Red + Hotpink + Teal + + + Submit +
+
+
+ `; + }) + .add('Render Options', () => { + const objs = [ + { type: 'mastercard', label: 'Master Card', amount: 12000, active: true }, + { type: 'visacard', label: 'Visa Card', amount: 0, active: false }, + ]; + + function showOutput() { + // eslint-disable-next-line no-undef + output.innerHTML = JSON.stringify(this.checkedValue, null, 2); + } + return html` + +
+ +
+ + + ${objs.map( + obj => html` + ${obj.label} + `, + )} + + +

+          
+
+
+ `; + }) + .add( + 'Interaction mode', + () => html` + +

By default the select-rich uses the same interaction-mode as the operating system.

+
+ + + Red + Hotpink + Teal + + + + + + Red + Hotpink + Teal + + +
+ `, + ); diff --git a/packages/select-rich/test/keyboardEventShimIE.js b/packages/select-rich/test/keyboardEventShimIE.js new file mode 100644 index 000000000..45e834217 --- /dev/null +++ b/packages/select-rich/test/keyboardEventShimIE.js @@ -0,0 +1,49 @@ +if (typeof window.KeyboardEvent !== 'function') { + // e.g. is IE and needs "polyfill" + const KeyboardEvent = (event, _params) => { + // current spec for it https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/KeyboardEvent + const params = { + bubbles: false, + cancelable: false, + view: document.defaultView, + key: false, + location: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + repeat: false, + ..._params, + }; + const modifiersListArray = []; + if (params.ctrlKey) { + modifiersListArray.push('Control'); + } + if (params.shiftKey) { + modifiersListArray.push('Shift'); + } + if (params.altKey) { + modifiersListArray.push('Alt'); + } + if (params.metaKey) { + modifiersListArray.push('Meta'); + } + + const ev = document.createEvent('KeyboardEvent'); + // IE Spec for it https://technet.microsoft.com/en-us/windows/ff975297(v=vs.60) + ev.initKeyboardEvent( + event, + params.bubbles, + params.cancelable, + params.view, + params.key, + params.location, + modifiersListArray.join(' '), + params.repeat ? 1 : 0, + params.locale, + ); + return ev; + }; + KeyboardEvent.prototype = window.Event.prototype; + window.KeyboardEvent = KeyboardEvent; +} diff --git a/packages/select-rich/test/lion-options.test.js b/packages/select-rich/test/lion-options.test.js new file mode 100644 index 000000000..903ba7fd6 --- /dev/null +++ b/packages/select-rich/test/lion-options.test.js @@ -0,0 +1,12 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +import '../lion-options.js'; + +describe('lion-options', () => { + it('should have role="listbox"', async () => { + const el = await fixture(html` + + `); + expect(el.role).to.equal('listbox'); + }); +}); diff --git a/packages/select-rich/test/lion-select-invoker.test.js b/packages/select-rich/test/lion-select-invoker.test.js new file mode 100644 index 000000000..fdec095ac --- /dev/null +++ b/packages/select-rich/test/lion-select-invoker.test.js @@ -0,0 +1,76 @@ +import { expect, fixture, html, defineCE } from '@open-wc/testing'; +import { LionButton } from '@lion/button'; +import { LionSelectInvoker } from '../src/LionSelectInvoker.js'; + +import '../lion-select-invoker.js'; + +describe('lion-select-invoker', () => { + it('should behave as a button', async () => { + const el = await fixture(html` + + `); + expect(el instanceof LionButton).to.be.true; + }); + + it('renders invoker info based on selectedElement child elements', async () => { + const el = await fixture(html` + + `); + el.selectedElement = await fixture(`

I am

2 lines

`); + await el.updateComplete; + + expect(el.contentWrapper).lightDom.to.equal( + ` +

I am

+

2 lines

+ `, + { + ignoreAttributes: ['class'], // ShadyCss automatically adds classes + }, + ); + }); + + it('renders invoker info based on selectedElement textContent', async () => { + const el = await fixture(html` + + `); + el.selectedElement = await fixture(`
just textContent
`); + await el.updateComplete; + + expect(el.contentWrapper).lightDom.to.equal('just textContent'); + }); + + it('has tabindex="0"', async () => { + const el = await fixture(html` + + `); + expect(el.tabIndex).to.equal(0); + expect(el.getAttribute('tabindex')).to.equal('0'); + }); + + describe('Subclassers', () => { + it('supports a custom _contentTemplate', async () => { + const myTag = defineCE( + class extends LionSelectInvoker { + _contentTemplate() { + if (this.selectedElement && this.selectedElement.textContent === 'cat') { + return html` + cat selected + `; + } + return `no valid selection`; + } + }, + ); + const el = await fixture(`<${myTag}>`); + + el.selectedElement = await fixture(`
cat
`); + await el.updateComplete; + expect(el.contentWrapper).lightDom.to.equal('cat selected'); + + el.selectedElement = await fixture(`
dog
`); + await el.updateComplete; + expect(el.contentWrapper).lightDom.to.equal('no valid selection'); + }); + }); +}); diff --git a/packages/select-rich/test/lion-select-rich-interaction.test.js b/packages/select-rich/test/lion-select-rich-interaction.test.js new file mode 100644 index 000000000..29abb7b37 --- /dev/null +++ b/packages/select-rich/test/lion-select-rich-interaction.test.js @@ -0,0 +1,657 @@ +import { expect, fixture, html, triggerFocusFor, triggerBlurFor } from '@open-wc/testing'; +import './keyboardEventShimIE.js'; + +import '@lion/option/lion-option.js'; +import '../lion-options.js'; +import '../lion-select-rich.js'; + +describe('lion-select-rich interactions', () => { + describe('values', () => { + it('registers options', async () => { + const el = await fixture(html` + + + Item 1 + 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` + + + Item 1 + Item 2 + + + `); + + expect(el.querySelector('lion-option').checked).to.be.true; + expect(el.querySelector('lion-option').active).to.be.true; + expect(el.modelValue).to.deep.equal([ + { value: 10, checked: true }, + { value: 20, checked: false }, + ]); + expect(el.checkedValue).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` + + + Please select value + Item 2 + + + `); + expect(el.modelValue).to.deep.equal([ + { value: null, checked: true }, + { value: 20, checked: false }, + ]); + expect(el.checkedValue).to.be.null; + }); + + it('has the checked option as modelValue', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + expect(el.modelValue).to.deep.equal([ + { value: 10, checked: false }, + { value: 20, checked: true }, + ]); + expect(el.checkedValue).to.equal(20); + }); + + it('syncs checkedValue to modelValue', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + el.checkedValue = 20; + expect(el.modelValue).to.deep.equal([ + { value: 10, checked: false }, + { value: 20, checked: true }, + ]); + }); + + it('has an activeIndex', async () => { + const el = await fixture(html` + + + Item 1 + 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` + + + Item 1 + + + `); + expect(() => { + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + }).to.not.throw(); + expect(el.checkedIndex).to.equal(0); + expect(el.activeIndex).to.equal(0); + }); + + it('navigates to first and last option with [Home] and [End] keys', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + Item 3 + Item 4 + + + `); + expect(el.checkedValue).to.equal(30); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' })); + expect(el.checkedValue).to.equal(10); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'End' })); + expect(el.checkedValue).to.equal(40); + }); + + // TODO: nice to have + it.skip('selects a value with single [character] key', async () => { + const el = await fixture(html` + + + A + B + C + + + `); + expect(el.choiceValue).to.equal('a'); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'C' })); + expect(el.choiceValue).to.equal('c'); + }); + + it.skip('selects a value with multiple [character] keys', async () => { + const el = await fixture(html` + + + Bar + Far + Foo + + + `); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'F' })); + expect(el.choiceValue).to.equal('far'); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'O' })); + expect(el.choiceValue).to.equal('foo'); + }); + }); + + 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('keyup', { key: 'ArrowDown' })); + expect(el.activeIndex).to.equal(1); + expect(el.checkedIndex).to.equal(1); + expectOnlyGivenOneOptionToBeChecked(options, 1); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); + expect(el.activeIndex).to.equal(0); + expect(el.checkedIndex).to.equal(0); + expectOnlyGivenOneOptionToBeChecked(options, 0); + }); + + it('navigates through list with [ArrowDown] [ArrowUp] keys checks the option while listbox unopened', 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.checkedIndex).to.equal(0); + expectOnlyGivenOneOptionToBeChecked(options, 0); + + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + expect(el.checkedIndex).to.equal(1); + expectOnlyGivenOneOptionToBeChecked(options, 1); + + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); + expect(el.checkedIndex).to.equal(0); + expectOnlyGivenOneOptionToBeChecked(options, 0); + }); + }); + + describe('Keyboard navigation Mac', () => { + it('navigates through open list with [ArrowDown] [ArrowUp] keys activates the option', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + Item 3 + + + `); + expect(el.activeIndex).to.equal(0); + expect(el.checkedIndex).to.equal(0); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + expect(el.activeIndex).to.equal(1); + expect(el.checkedIndex).to.equal(0); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); + expect(el.activeIndex).to.equal(0); + expect(el.checkedIndex).to.equal(0); + }); + }); + + describe('Disabled', () => { + it('cannot be focused if disabled', async () => { + const el = await fixture(html` + + `); + expect(el._invokerNode.tabIndex).to.equal(-1); + }); + + it('still has a checked value', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + expect(el.checkedValue).to.equal(10); + }); + + it('cannot be navigated with keyboard if disabled', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + expect(el.checkedValue).to.equal(10); + }); + + it('cannot be opened via click if disabled', async () => { + const el = await fixture(html` + + `); + el._invokerNode.click(); + expect(el.opened).to.be.false; + }); + + it('reflects disabled attribute to invoker', async () => { + const el = await fixture(html` + + `); + expect(el._invokerNode.hasAttribute('disabled')).to.be.true; + el.removeAttribute('disabled'); + await el.updateComplete; + expect(el._invokerNode.hasAttribute('disabled')).to.be.false; + }); + + it('skips disabled options while navigating through list with [ArrowDown] [ArrowUp] keys', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + Item 3 + + + `); + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + expect(el.activeIndex).to.equal(2); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); + expect(el.activeIndex).to.equal(0); + }); + + it('skips disabled options while navigates to first and last option with [Home] and [End] keys', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + Item 3 + Item 4 + + + `); + expect(el.activeIndex).to.equal(2); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' })); + expect(el.activeIndex).to.equal(1); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'End' })); + expect(el.activeIndex).to.equal(2); + }); + + it('checks the first enabled option', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + 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` + + + Item 1 + 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` + + + Item 1 + 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; + }); + }); + + // TODO: nice to have + describe.skip('Read only', () => { + it('can be focused if readonly', async () => { + const el = await fixture(html` + + `); + expect(el.tabIndex).to.equal('-1'); + }); + + it('cannot be navigated with keyboard if readonly', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + expect(el.choiceValue).to.equal(10); + }); + }); + + describe('Programmatic interaction', () => { + it('can set active state', async () => { + const el = await fixture(html` + + + Item 1 + 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` + + + Item 1 + Item 2 + + + `); + const option = el.querySelectorAll('lion-option')[1]; + option.checked = true; + expect(el.modelValue).to.deep.equal([ + { value: 10, checked: false }, + { value: 20, checked: true }, + ]); + }); + + it('does not allow to set checkedIndex or activeIndex to be out of bound', async () => { + const el = await fixture(html` + + + 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` + + + Item 1 + 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` + + + Item 1 + 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` + + + Item 1 + Item 2 + + + `); + expect(el.dirty).to.be.false; + el.checkedValue = 20; + expect(el.dirty).to.be.true; + }); + + it('becomes touched if blurred once', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + expect(el.touched).to.be.false; + await triggerFocusFor(el._invokerNode); + await triggerBlurFor(el._invokerNode); + expect(el.touched).to.be.true; + }); + + it('is prefilled if there is a value on init', async () => { + const el = await fixture(html` + + + Item 1 + + + `); + expect(el.prefilled).to.be.true; + + const elEmpty = await fixture(html` + + + Please select a value + Item 1 + + + `); + expect(elEmpty.prefilled).to.be.false; + }); + }); + + describe('Validation', () => { + it('can be required', async () => { + const el = await fixture(html` + + + Please select a value + Item 2 + + + `); + expect(el.error.required).to.be.true; + el.checkedValue = 20; + expect(el.error.required).to.be.undefined; + }); + }); + + describe('Accessibility', () => { + it('creates unique ids for all children', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + 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` + + + Item 1 + Item 2 + + + `); + expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('first'); + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { 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` + + + Item 1 + Item 2 + 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` + + + Item 1 + Item 2 + 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/select-rich/test/lion-select-rich.test.js b/packages/select-rich/test/lion-select-rich.test.js new file mode 100644 index 000000000..d1c70d369 --- /dev/null +++ b/packages/select-rich/test/lion-select-rich.test.js @@ -0,0 +1,321 @@ +import { expect, fixture, html, aTimeout } from '@open-wc/testing'; +import './keyboardEventShimIE.js'; + +import '@lion/option/lion-option.js'; +import '../lion-options.js'; +import '../lion-select-rich.js'; + +describe('lion-select-rich', () => { + it('does not have a tabindex', async () => { + const el = await fixture(html` + + `); + expect(el.hasAttribute('tabindex')).to.be.false; + }); + + describe('Invoker', () => { + it('generates an lion-select-invoker if no invoker is provided', async () => { + const el = await fixture(html` + + `); + + expect(el._invokerNode).to.exist; + expect(el._invokerNode.tagName).to.equal('LION-SELECT-INVOKER'); + }); + + it('syncs the selected element to the invoker', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + const options = Array.from(el.querySelectorAll('lion-option')); + expect(el._invokerNode.selectedElement).to.equal(options[0]); + + el.checkedIndex = 1; + expect(el._invokerNode.selectedElement).to.equal(el.querySelectorAll('lion-option')[1]); + }); + }); + + describe('overlay', () => { + it('should be closed by default', async () => { + const el = await fixture(html` + + `); + expect(el.opened).to.be.false; + }); + + it('shows/hides the listbox via opened attribute', async () => { + const el = await fixture(html` + + + + `); + el.opened = true; + await el.updateComplete; + expect(el._listboxNode.style.display).to.be.equal('inline-block'); + + el.opened = false; + await el.updateComplete; + expect(el._listboxNode.style.display).to.be.equal('none'); + }); + + it('syncs opened state with overlay shown', async () => { + const el = await fixture(html` + + + + `); + const outerEl = await fixture(''); + + expect(el.opened).to.be.true; + // a click on the button will trigger hide on outside click + // which we then need to sync back to "opened" + outerEl.click(); + await aTimeout(); + expect(el.opened).to.be.false; + }); + + it('will focus the listbox on open and invoker on close', async () => { + const el = await fixture(html` + + + + `); + el.opened = true; + await el.updateComplete; + expect(document.activeElement === el._listboxNode).to.be.true; + expect(document.activeElement === el._invokerNode).to.be.false; + + el.opened = false; + await el.updateComplete; + expect(document.activeElement === el._listboxNode).to.be.false; + expect(document.activeElement === el._invokerNode).to.be.true; + }); + + it('opens the listbox with checked option as active', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + el.opened = true; + await el.updateComplete; + const options = Array.from(el.querySelectorAll('lion-option')); + + expect(options[1].active).to.be.true; + expect(options[1].checked).to.be.true; + }); + }); + + describe('interaction-mode', () => { + it('allows to specify an interaction-mode which determines other behaviors', async () => { + const el = await fixture(html` + + `); + expect(el.interactionMode).to.equal('mac'); + }); + }); + + describe('Keyboard navigation', () => { + it('opens the listbox with [Enter] key via click handler', async () => { + const el = await fixture(html` + + + + `); + el._invokerNode.click(); + await el.updateComplete; + expect(el.opened).to.be.true; + }); + + it('opens the listbox with [ ](Space) key via click handler', async () => { + const el = await fixture(html` + + + + `); + el._invokerNode.click(); + await el.updateComplete; + expect(el.opened).to.be.true; + }); + + it('closes the listbox with [Escape] key once opened', async () => { + const el = await fixture(html` + + + + `); + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + await el.updateComplete; + expect(el.opened).to.be.false; + }); + + it('closes the listbox with [Tab] key once opened', async () => { + const el = await fixture(html` + + + + `); + // tab can only be caught via keydown + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + await el.updateComplete; + expect(el.opened).to.be.false; + }); + }); + + describe('Mouse navigation', () => { + it('opens the listbox via click on invoker', async () => { + const el = await fixture(html` + + + + `); + expect(el.opened).to.be.false; + el._invokerNode.click(); + expect(el.opened).to.be.true; + }); + + it('closes the listbox when an option gets clicked', async () => { + const el = await fixture(html` + + + Item 1 + + + `); + expect(el.opened).to.be.true; + el.querySelector('lion-option').click(); + expect(el.opened).to.be.false; + }); + }); + + describe('Keyboard navigation Windows', () => { + it('closes the listbox with [Enter] key once opened', async () => { + const el = await fixture(html` + + + + `); + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + await el.updateComplete; + expect(el.opened).to.be.false; + }); + }); + + describe('Keyboard navigation Mac', () => { + it('checks active item and closes the listbox with [Enter] key via click handler once opened', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + + // changes active but not checked + el.activeIndex = 1; + expect(el.checkedIndex).to.equal(0); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + expect(el.opened).to.be.false; + expect(el.checkedIndex).to.equal(1); + }); + + it('opens the listbox with [ArrowUp] key', async () => { + const el = await fixture(html` + + + + `); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); + await el.updateComplete; + expect(el.opened).to.be.true; + }); + + it('opens the listbox with [ArrowDown] key', async () => { + const el = await fixture(html` + + + + `); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + await el.updateComplete; + expect(el.opened).to.be.true; + }); + }); + + describe('Accessibility', () => { + it('has the right references to its inner elements', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + expect(el._invokerNode.getAttribute('aria-labelledby')).to.contain(el._labelNode.id); + expect(el._invokerNode.getAttribute('aria-labelledby')).to.contain(el._invokerNode.id); + expect(el._invokerNode.getAttribute('aria-describedby')).to.contain(el._helpTextNode.id); + expect(el._invokerNode.getAttribute('aria-describedby')).to.contain(el._feedbackNode.id); + expect(el._invokerNode.getAttribute('aria-haspopup')).to.equal('listbox'); + }); + + it('notifies when the listbox is expanded or not', async () => { + // smoke test for overlay functionality + const el = await fixture(html` + + + + `); + expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false'); + el.opened = true; + await el.updateComplete; + expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('true'); + }); + }); + + describe('Use cases', () => { + it('works for complex array data', async () => { + const objs = [ + { type: 'mastercard', label: 'Master Card', amount: 12000, active: true }, + { type: 'visacard', label: 'Visa Card', amount: 0, active: false }, + ]; + const el = await fixture(html` + + + ${objs.map( + obj => html` + ${obj.label} + `, + )} + + + `); + expect(el.checkedValue).to.deep.equal({ + type: 'mastercard', + label: 'Master Card', + amount: 12000, + active: true, + }); + + el.checkedIndex = 1; + expect(el.checkedValue).to.deep.equal({ + type: 'visacard', + label: 'Visa Card', + amount: 0, + active: false, + }); + }); + }); +}); diff --git a/stories/index.stories.js b/stories/index.stories.js index 00329be36..dbf8e0500 100755 --- a/stories/index.stories.js +++ b/stories/index.stories.js @@ -24,3 +24,6 @@ import '../packages/overlays/stories/index.stories.js'; import '../packages/popup/stories/index.stories.js'; import '../packages/tooltip/stories/index.stories.js'; import '../packages/calendar/stories/index.stories.js'; + +import '../packages/option/stories/index.stories.js'; +import '../packages/select-rich/stories/index.stories.js';