Merge pull request #156 from ing-bank/feat/implSelect

feat: add select-rich
This commit is contained in:
Joren Broekema 2019-07-25 17:38:48 +02:00 committed by GitHub
commit 784462d65a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 2577 additions and 0 deletions

49
packages/option/README.md Normal file
View file

@ -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
<lion-select-rich
name="favoriteColor"
label="Favorite color"
.errorValidators=${[['required']]}
>
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
</lion-options>
</lion-select-rich>
```
You can also set the full modelValue for each option.
```html
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
```
For more details please see [lion-select-rich](../select-rich/).

1
packages/option/index.js Normal file
View file

@ -0,0 +1 @@
export { LionOption } from './src/LionOption.js';

View file

@ -0,0 +1,3 @@
import { LionOption } from './src/LionOption.js';
customElements.define('lion-option', LionOption);

View file

@ -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"
}
}

View file

@ -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`
<div class="choice-field__label">
<slot></slot>
</div>
`;
}
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);
}
}

View file

@ -0,0 +1,35 @@
import { storiesOf, html } from '@open-wc/demoing-storybook';
import '../lion-option.js';
storiesOf('Forms|Option', module)
.add(
'States',
() => html`
<lion-option>Default</lion-option><br />
<lion-option disabled>Disabled</lion-option>
<lion-option>
<p style="color: red;">With html</p>
<p>and multi Line</p>
</lion-option>
`,
)
.add(
'Values',
() => html`
<lion-option .modelValue=${{ value: 10, checked: false }}>setting modelValue</lion-option>
<lion-option .modelValue=${{ value: 10, checked: false }} active
>setting modelValue active</lion-option
>
<lion-option .modelValue=${{ value: 10, checked: true }}
>setting modelValue checked</lion-option
>
<lion-option .modelValue=${{ value: 10, checked: false }} disabled
>setting modelValue disabled</lion-option
>
<lion-option .choiceValue=${10}>setting choiceValue</lion-option>
<lion-option .choiceValue=${10} active>setting choiceValue active</lion-option>
<lion-option .choiceValue=${10} checked>setting choiceValue checked</lion-option>
<lion-option .choiceValue=${10} disabled>setting choiceValue disabled</lion-option>
`,
);

View file

@ -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`
<lion-option .choiceValue=${10}></lion-option>
`);
expect(el.modelValue).to.deep.equal({ value: 10, checked: false });
});
it('can be checked', async () => {
const el = await fixture(html`
<lion-option .choiceValue=${10} checked></lion-option>
`);
expect(el.modelValue).to.deep.equal({ value: 10, checked: true });
});
});
describe('Accessibility', () => {
it('has the "option" role', async () => {
const el = await fixture(html`
<lion-option></lion-option>
`);
expect(el.getAttribute('role')).to.equal('option');
});
it('has "aria-selected" attribute when checked', async () => {
const el = await fixture(html`
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
`);
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`
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
`);
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`
<lion-option .choiceValue=${10}></lion-option>
`);
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`
<lion-option .choiceValue=${10}></lion-option>
`);
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`
<lion-option .choiceValue=${10} active></lion-option>
`);
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`
<lion-option .choiceValue=${10}></lion-option>
`);
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`
<lion-option .choiceValue=${10} @active-changed="${activeSpy}"></lion-option>
`);
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`
<lion-option .choiceValue=${10} disabled></lion-option>
`);
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`
<lion-option .choiceValue=${10} disabled></lion-option>
`);
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`
<lion-option .choiceValue=${10} active disabled></lion-option>
`);
expect(el.active).to.be.true;
el.dispatchEvent(new Event('mouseleave'));
expect(el.active).to.be.true;
});
});
});

View file

@ -0,0 +1,79 @@
# Select Rich
[//]: # 'AUTO INSERT HEADER PREPUBLISH'
`lion-select-rich` component is a 'rich' version of the native `<select>` element.
It allows to provide fully customized options and a fully customized invoker button.
The component is meant to be used whenever the native `<select>` doesn't provide enough
styling/theming/user interaction opportunities.
Its implementation is based on the following Design pattern:
<https://www.w3.org/TR/wai-aria-practices/examples/listbox/listbox-collapsible.html>
## 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
<lion-select-rich
name="favoriteColor"
label="Favorite color"
.errorValidators=${[['required']]}
>
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
</lion-options>
</lion-select-rich>
```
You can also set the full modelValue for each option.
```html
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
```
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
<lion-select-rich>
<my-invoker-button slot="invoker"></my-invoker-button>
<lion-options slot="input">
...
</lion-options>
</lion-select-rich>
```
## Other Resources
- [Design Considerations](./docs/DesignConsiderations.md)

View file

@ -0,0 +1,3 @@
import { LionOptions } from './src/LionOptions.js';
customElements.define('lion-options', LionOptions);

View file

@ -0,0 +1,3 @@
import { LionSelectInvoker } from './src/LionSelectInvoker.js';
customElements.define('lion-select-invoker', LionSelectInvoker);

View file

@ -0,0 +1,3 @@
import { LionSelectRich } from './src/LionSelectRich.js';
customElements.define('lion-select-rich', LionSelectRich);

View file

@ -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"
}
}

View file

@ -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;
}
}

View file

@ -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`
<div id="content-wrapper">
${this._contentTemplate()}
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
_renderAfter() {
return html`
<slot name="after"></slot>
`;
}
}

View file

@ -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 <lion-listbox> 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`
<div class="input-group__input">
<slot name="invoker"></slot>
<slot name="input"></slot>
</div>
`;
}
/**
* 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),
};
}
}

View file

@ -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;
},
});
}

View file

@ -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`
<style>
${selectRichDemoStyle}
</style>
<div class="demo-area">
<lion-select-rich label="Favorite color" name="color">
<lion-options slot="input">
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-options>
</lion-select-rich>
</div>
`,
)
.add(
'Options with HTML',
() => html`
<style>
${selectRichDemoStyle}
</style>
<div class="demo-area">
<lion-select-rich label="Favorite color" name="color">
<lion-options slot="input" class="demo-listbox">
<lion-option .modelValue=${{ value: 'red', checked: false }}>
<p style="color: red;">I am red</p>
<p>and multi Line</p>
</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>
<p style="color: hotpink;">I am hotpink</p>
<p>and multi Line</p>
</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>
<p style="color: teal;">I am teal</p>
<p>and multi Line</p>
</lion-option>
</lion-options>
</lion-select-rich>
</div>
`,
)
.add(
'Disabled',
() => html`
<style>
${selectRichDemoStyle}
</style>
<div class="demo-area">
<lion-select-rich label="Disabled select" disabled name="color">
<lion-options slot="input">
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-options>
</lion-select-rich>
<lion-select-rich label="Disabled options" name="color">
<lion-options slot="input">
<lion-option .choiceValue=${'red'} disabled>Red</lion-option>
<lion-option .choiceValue=${'blue'}>Blue</lion-option>
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
<lion-option .choiceValue=${'green'}>Green</lion-option>
<lion-option .choiceValue=${'teal'} disabled>Teal</lion-option>
</lion-options>
</lion-select-rich>
</div>
`,
)
.add('Validation', () => {
const submit = () => {
const form = document.querySelector('#form');
if (form.errorState === false) {
console.log(form.serializeGroup());
}
};
return html`
<style>
${selectRichDemoStyle}
</style>
<div class="demo-area">
<lion-form id="form" @submit="${submit}">
<form>
<lion-select-rich
id="color"
name="color"
label="Favorite color"
.errorValidators="${[['required']]}"
>
<lion-options slot="input" class="demo-listbox">
<lion-option .choiceValue=${null}>select a color</lion-option>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
</lion-select-rich>
<lion-button type="submit">Submit</lion-button>
</form>
</lion-form>
</div>
`;
})
.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`
<style>
${selectRichDemoStyle}
</style>
<div class="demo-area">
<lion-form>
<form>
<lion-select-rich
label="Credit Card"
name="color"
@select-model-value-changed=${showOutput}
>
<lion-options slot="input">
${objs.map(
obj => html`
<lion-option .modelValue=${{ value: obj, checked: false }}
>${obj.label}</lion-option
>
`,
)}
</lion-options>
</lion-select-rich>
<pre id="output"></pre>
</form>
</lion-form>
</div>
`;
})
.add(
'Interaction mode',
() => html`
<style>
${selectRichDemoStyle}
</style>
<p>By default the select-rich uses the same interaction-mode as the operating system.</p>
<div class="demo-area">
<lion-select-rich label="Mac mode" name="color" interaction-mode="mac">
<lion-options slot="input">
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-options>
</lion-select-rich>
<lion-select-rich label="Windows/Linux mode" name="color" interaction-mode="windows/linux">
<lion-options slot="input">
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-options>
</lion-select-rich>
</div>
`,
);

View file

@ -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;
}

View file

@ -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`
<lion-options></lion-options>
`);
expect(el.role).to.equal('listbox');
});
});

View file

@ -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`
<lion-select-invoker></lion-select-invoker>
`);
expect(el instanceof LionButton).to.be.true;
});
it('renders invoker info based on selectedElement child elements', async () => {
const el = await fixture(html`
<lion-select-invoker></lion-select-invoker>
`);
el.selectedElement = await fixture(`<div class="option"><h2>I am</h2><p>2 lines</p></div>`);
await el.updateComplete;
expect(el.contentWrapper).lightDom.to.equal(
`
<h2>I am</h2>
<p>2 lines</p>
`,
{
ignoreAttributes: ['class'], // ShadyCss automatically adds classes
},
);
});
it('renders invoker info based on selectedElement textContent', async () => {
const el = await fixture(html`
<lion-select-invoker></lion-select-invoker>
`);
el.selectedElement = await fixture(`<div class="option">just textContent</div>`);
await el.updateComplete;
expect(el.contentWrapper).lightDom.to.equal('just textContent');
});
it('has tabindex="0"', async () => {
const el = await fixture(html`
<lion-select-invoker></lion-select-invoker>
`);
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}></${myTag}>`);
el.selectedElement = await fixture(`<div class="option">cat</div>`);
await el.updateComplete;
expect(el.contentWrapper).lightDom.to.equal('cat selected');
el.selectedElement = await fixture(`<div class="option">dog</div>`);
await el.updateComplete;
expect(el.contentWrapper).lightDom.to.equal('no valid selection');
});
});
});

View file

@ -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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${null}>Please select value</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened interaction-mode="windows/linux">
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30} checked>Item 3</lion-option>
<lion-option .choiceValue=${40}>Item 4</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${'a'}>A</lion-option>
<lion-option .choiceValue=${'b'}>B</lion-option>
<lion-option .choiceValue=${'c'}>C</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${'bar'}>Bar</lion-option>
<lion-option .choiceValue=${'far'}>Far</lion-option>
<lion-option .choiceValue=${'foo'}>Foo</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened interaction-mode="windows/linux">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich interaction-mode="windows/linux">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened interaction-mode="mac">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich disabled></lion-select-rich>
`);
expect(el._invokerNode.tabIndex).to.equal(-1);
});
it('still has a checked value', async () => {
const el = await fixture(html`
<lion-select-rich disabled>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
expect(el.checkedValue).to.equal(10);
});
it('cannot be navigated with keyboard if disabled', async () => {
const el = await fixture(html`
<lion-select-rich disabled>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich disabled> </lion-select-rich>
`);
el._invokerNode.click();
expect(el.opened).to.be.false;
});
it('reflects disabled attribute to invoker', async () => {
const el = await fixture(html`
<lion-select-rich disabled> </lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} disabled>Item 2</lion-option>
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30} checked>Item 3</lion-option>
<lion-option .choiceValue=${40} disabled>Item 4</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input">
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input">
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich disabled>
<lion-options slot="input">
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-listbox readonly> </lion-listbox>
`);
expect(el.tabIndex).to.equal('-1');
});
it('cannot be navigated with keyboard if readonly', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} id="myId">Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
const 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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
const 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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
const 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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-options>
</lion-select-rich>
`);
expect(el.prefilled).to.be.true;
const elEmpty = await fixture(html`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${null}>Please select a value</lion-option>
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-options>
</lion-select-rich>
`);
expect(elEmpty.prefilled).to.be.false;
});
});
describe('Validation', () => {
it('can be required', async () => {
const el = await fixture(html`
<lion-select-rich .errorValidators=${['required']}>
<lion-options slot="input">
<lion-option .choiceValue=${null}>Please select a value</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} selected>Item 2</lion-option>
<lion-option .choiceValue=${30} id="predefined">Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${10} id="first">Item 1</lion-option>
<lion-option .choiceValue=${20} checked id="second">Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
optionEls.forEach((oEl, i) => {
expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`);
});
});
});
});

View file

@ -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`
<lion-select-rich></lion-select-rich>
`);
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`
<lion-select-rich></lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
const 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`
<lion-select-rich></lion-select-rich>
`);
expect(el.opened).to.be.false;
});
it('shows/hides the listbox via opened attribute', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const outerEl = await fixture('<button>somewhere</button>');
expect(el.opened).to.be.true;
// a click on the button will trigger hide on outside click
// which we then need to sync back to "opened"
outerEl.click();
await aTimeout();
expect(el.opened).to.be.false;
});
it('will focus the listbox on open and invoker on close', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich interaction-mode="mac"></lion-select-rich>
`);
expect(el.interactionMode).to.equal('mac');
});
});
describe('Keyboard navigation', () => {
it('opens the listbox with [Enter] key via click handler', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
// 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`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich opened interaction-mode="mac">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
// 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`
<lion-select-rich interaction-mode="mac">
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
await el.updateComplete;
expect(el.opened).to.be.true;
});
it('opens the listbox with [ArrowDown] key', async () => {
const el = await fixture(html`
<lion-select-rich interaction-mode="mac">
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich label="age">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
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`
<lion-select-rich label="Favorite color" name="color">
<lion-options slot="input">
${objs.map(
obj => html`
<lion-option .modelValue=${{ value: obj, checked: false }}
>${obj.label}</lion-option
>
`,
)}
</lion-options>
</lion-select-rich>
`);
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,
});
});
});
});

View file

@ -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';