feat(listbox): new package 'listbox'

This commit is contained in:
Thijs Louisse 2020-09-07 12:33:36 +02:00 committed by Thomas Allmer
parent e42071d8dc
commit 0ec72ac330
34 changed files with 2339 additions and 1429 deletions

View file

@ -0,0 +1,6 @@
---
'@lion/listbox': minor
'@lion/select-rich': patch
---
listbox package

View file

@ -1,6 +1,6 @@
--- ---
'@lion/button': patch '@lion/button': patch
'@lion/overlays': patch '@lion/overlays': minor
'@lion/tooltip': patch '@lion/tooltip': patch
--- ---

View file

@ -0,0 +1 @@

250
packages/listbox/README.md Normal file
View file

@ -0,0 +1,250 @@
# Listbox
A listbox widget presents a list of options and allows a user to select one or more of them.
A listbox that allows a single option to be chosen is a single-select listbox; one that allows
multiple options to be selected is a multi-select listbox.
> From [listbox wai-aria best practices](https://www.w3.org/TR/wai-aria-practices/#Listbox)
```js script
import { html } from 'lit-html';
import { Required } from '@lion/form-core';
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
import { listboxData } from './docs/listboxData.js';
import './lion-option.js';
import './lion-listbox.js';
export default {
title: 'Forms/Listbox',
};
```
```js preview-story
export const main = () => html`
<lion-listbox name="listbox" label="Default">
<lion-option .choiceValue="${'Apple'}>Apple</lion-option>
<lion-option .choiceValue="${'Artichoke'}>Artichoke</lion-option>
<lion-option .choiceValue="${'Asparagus'}>Asparagus</lion-option>
<lion-option .choiceValue="${'Banana'}>Banana</lion-option>
<lion-option .choiceValue="${'Beets'}>Beets</lion-option>
</lion-listbox>
`;
```
## Multiple choice
Add `multiple-choice` flag to allow multiple values to be selected.
This will:
- keep the listbox overlay open on click of an option
- display a list of selected option representations next to the text box
- make the value of type `Array` instead of `String`
```js preview-story
export const multiple = () => html`
<lion-listbox name="combo" label="Multiple" multiple-choice>
<lion-option
.choiceValue="${'Apple'}>Apple</lion-option>
<lion-option .choiceValue="
${'Artichoke'}
>Artichoke</lion-option
>
<lion-option
.choiceValue="${'Asparagus'}>Asparagus</lion-option>
<lion-option .choiceValue="
${'Banana'}
>Banana</lion-option
>
<lion-option
.choiceValue="${'Beets'}>Beets</lion-option>
<lion-option .choiceValue="
${'Bell pepper'}
>Bell pepper</lion-option
>
<lion-option
.choiceValue="${'Broccoli'}>Broccoli</lion-option>
<lion-option .choiceValue="
${'Brussels sprout'}
>Brussels sprout</lion-option
>
<lion-option
.choiceValue="${'Cabbage'}>Cabbage</lion-option>
<lion-option .choiceValue="
${'Carrot'}
>Carrot</lion-option
>
</lion-listbox>
`;
```
## Orientation
When `orientation="horizontal"`, left and right arrow keys will be enabled, plus the screenreader
will be informed about the direction of the options.
By default, `orientation="horizontal"` is set, which enables up and down arrow keys.
```js preview-story
export const orientationHorizontal = () => html`
<lion-listbox name="combo" label="Orientation horizontal" orientation="horizontal">
<lion-option
.choiceValue="${'Apple'}>Apple</lion-option>
<lion-option .choiceValue="
${'Artichoke'}
>Artichoke</lion-option
>
<lion-option
.choiceValue="${'Asparagus'}>Asparagus</lion-option>
<lion-option .choiceValue="
${'Banana'}
>Banana</lion-option
>
<lion-option
.choiceValue="${'Beets'}>Beets</lion-option>
<lion-option .choiceValue="
${'Bell pepper'}
>Bell pepper</lion-option
>
<lion-option
.choiceValue="${'Broccoli'}>Broccoli</lion-option>
<lion-option .choiceValue="
${'Brussels sprout'}
>Brussels sprout</lion-option
>
<lion-option
.choiceValue="${'Cabbage'}>Cabbage</lion-option>
<lion-option .choiceValue="
${'Carrot'}
>Carrot</lion-option
>
</lion-listbox>
`;
```
With `multiple-choice` flag configured, multiple options can be checked.
```js preview-story
export const orientationHorizontalMultiple = () => html`
<lion-listbox
name="combo"
label="Orientation horizontal multiple"
orientation="horizontal"
multiple-choice
>
<lion-option
.choiceValue="${'Apple'}>Apple</lion-option>
<lion-option .choiceValue="
${'Artichoke'}
>Artichoke</lion-option
>
<lion-option
.choiceValue="${'Asparagus'}>Asparagus</lion-option>
<lion-option .choiceValue="
${'Banana'}
>Banana</lion-option
>
<lion-option
.choiceValue="${'Beets'}>Beets</lion-option>
<lion-option .choiceValue="
${'Bell pepper'}
>Bell pepper</lion-option
>
<lion-option
.choiceValue="${'Broccoli'}>Broccoli</lion-option>
<lion-option .choiceValue="
${'Brussels sprout'}
>Brussels sprout</lion-option
>
<lion-option
.choiceValue="${'Cabbage'}>Cabbage</lion-option>
<lion-option .choiceValue="
${'Carrot'}
>Carrot</lion-option
>
</lion-listbox>
`;
```
## Selection-follows-focus
When true, will synchronize activedescendant and selected element on arrow key navigation.
This behavior can usually be seen in `<select>` on the Windows platform.
Note that this behavior cannot be used when multiple-choice is true.
See [wai aria spec](https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus)
```js preview-story
export const selectionFollowsFocus = () => html`
<lion-listbox name="combo" label="Selection follows focus" selection-follows-focus>
<lion-option
.choiceValue="${'Apple'}>Apple</lion-option>
<lion-option .choiceValue="
${'Artichoke'}
>Artichoke</lion-option
>
<lion-option
.choiceValue="${'Asparagus'}>Asparagus</lion-option>
<lion-option .choiceValue="
${'Banana'}
>Banana</lion-option
>
<lion-option
.choiceValue="${'Beets'}>Beets</lion-option>
<lion-option .choiceValue="
${'Bell pepper'}
>Bell pepper</lion-option
>
<lion-option
.choiceValue="${'Broccoli'}>Broccoli</lion-option>
<lion-option .choiceValue="
${'Brussels sprout'}
>Brussels sprout</lion-option
>
<lion-option
.choiceValue="${'Cabbage'}>Cabbage</lion-option>
<lion-option .choiceValue="
${'Carrot'}
>Carrot</lion-option
>
</lion-listbox>
`;
```
## Rotate keyboard navigation
Will give first option active state when navigated to the next option from last option.
```js preview-story
export const rotateKeyboardNavigation = () => html`
<lion-listbox name="combo" label="Rotate keyboard navigation" rotate-keyboard-navigation>
<lion-option
.choiceValue="${'Apple'}>Apple</lion-option>
<lion-option .choiceValue="
${'Artichoke'}
>Artichoke</lion-option
>
<lion-option
.choiceValue="${'Asparagus'}>Asparagus</lion-option>
<lion-option .choiceValue="
${'Banana'}
>Banana</lion-option
>
<lion-option
.choiceValue="${'Beets'}>Beets</lion-option>
<lion-option .choiceValue="
${'Bell pepper'}
>Bell pepper</lion-option
>
<lion-option
.choiceValue="${'Broccoli'}>Broccoli</lion-option>
<lion-option .choiceValue="
${'Brussels sprout'}
>Brussels sprout</lion-option
>
<lion-option
.choiceValue="${'Cabbage'}>Cabbage</lion-option>
<lion-option .choiceValue="
${'Carrot'}
>Carrot</lion-option
>
</lion-listbox>
`;
```

View file

@ -0,0 +1,65 @@
export const listboxData = [
'Apple',
'Artichoke',
'Asparagus',
'Banana',
'Beets',
'Bell pepper',
'Broccoli',
'Brussels sprout',
'Cabbage',
'Carrot',
'Cauliflower',
'Celery',
'Chard',
'Chicory',
'Corn',
'Cucumber',
'Daikon',
'Date',
'Edamame',
'Eggplant',
'Elderberry',
'Fennel',
'Fig',
'Garlic',
'Grape',
'Honeydew melon',
'Iceberg lettuce',
'Jerusalem artichoke',
'Kale',
'Kiwi',
'Leek',
'Lemon',
'Mango',
'Mangosteen',
'Melon',
'Mushroom',
'Nectarine',
'Okra',
'Olive',
'Onion',
'Orange',
'Parship',
'Pea',
'Pear',
'Pineapple',
'Potato',
'Pumpkin',
'Quince',
'Radish',
'Rhubarb',
'Shallot',
'Spinach',
'Squash',
'Strawberry',
'Sweet potato',
'Tomato',
'Turnip',
'Ugli fruit',
'Victoria plum',
'Watercress',
'Watermelon',
'Yam',
'Zucchini',
];

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,46 @@
{
"name": "@lion/listbox",
"version": "0.0.0",
"description": "A listbox widget presents a list of options and allows a user to select one or more of them",
"license": "MIT",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/listbox"
},
"main": "index.js",
"module": "index.js",
"files": [
"*.d.ts",
"*.js",
"docs",
"src",
"test",
"test-suites",
"translations",
"types"
],
"scripts": {
"prepublishOnly": "../../scripts/npm-prepublish.js",
"test": "cd ../../ && yarn test:browser --grep \"packages/listbox/test/**/*.test.js\"",
"test:watch": "cd ../../ && yarn test:browser:watch --grep \"packages/listbox/test/**/*.test.js\""
},
"sideEffects": [
"lion-listbox.js"
],
"dependencies": {
"@lion/core": "0.11.0",
"@lion/form-core": "0.6.0"
},
"keywords": [
"form",
"lion",
"listbox",
"web-components"
],
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,14 @@
import { LitElement } from '@lion/core';
import { ValidateMixin, InteractionStateMixin, FocusMixin } from '@lion/form-core';
import { ListboxMixin } from './ListboxMixin.js';
// TODO: could we extend from LionField?
/**
* LionListbox: implements the wai-aria listbox design pattern and integrates it as a Lion
* FormControl
*/
// @ts-expect-error
export class LionListbox extends ListboxMixin(
FocusMixin(InteractionStateMixin(ValidateMixin(LitElement))),
) {}

View file

@ -1,6 +1,11 @@
import { ChoiceInputMixin, FormRegisteringMixin } from '@lion/form-core'; import { ChoiceInputMixin, FormRegisteringMixin } from '@lion/form-core';
import { css, DisabledMixin, html, LitElement } from '@lion/core'; import { css, DisabledMixin, html, LitElement } from '@lion/core';
/**
* @typedef {import('@lion/form-core/types/choice-group/ChoiceGroupMixinTypes').ChoiceGroupHost } ChoiceGroupHost
* @typedef {import('../types/LionOption').LionOptionHost } LionOptionHost
*/
/** /**
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option
* Can be a child of datalist/select, or role="listbox" * Can be a child of datalist/select, or role="listbox"
@ -8,6 +13,7 @@ import { css, DisabledMixin, html, LitElement } from '@lion/core';
* Element gets state supplied externally, reflects this to attributes, * Element gets state supplied externally, reflects this to attributes,
* enabling SubClassers to style based on those states * enabling SubClassers to style based on those states
*/ */
// @ts-expect-error
export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMixin(LitElement))) { export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMixin(LitElement))) {
static get properties() { static get properties() {
return { return {
@ -15,10 +21,6 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
type: Boolean, type: Boolean,
reflect: true, reflect: true,
}, },
name: {
type: String,
reflect: true,
},
}; };
} }
@ -29,14 +31,17 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
display: block; display: block;
background-color: white; background-color: white;
padding: 4px; padding: 4px;
cursor: default;
} }
:host([hidden]) { :host([hidden]) {
display: none; display: none;
} }
:host([active]),
:host(:hover) { :host(:hover) {
background-color: #eee;
}
:host([active]) {
background-color: #ddd; background-color: #ddd;
} }
@ -65,6 +70,10 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
this.__registerEventListeners(); this.__registerEventListeners();
} }
/**
* @param {string} name
* @param {unknown} oldValue
*/
requestUpdateInternal(name, oldValue) { requestUpdateInternal(name, oldValue) {
super.requestUpdateInternal(name, oldValue); super.requestUpdateInternal(name, oldValue);
@ -73,6 +82,9 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
} }
} }
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('checked')) { if (changedProperties.has('checked')) {
@ -98,15 +110,22 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
} }
__registerEventListeners() { __registerEventListeners() {
this.__onClick = () => {
if (!this.disabled) {
this.checked = true;
}
};
this.addEventListener('click', this.__onClick); this.addEventListener('click', this.__onClick);
} }
__unRegisterEventListeners() { __unRegisterEventListeners() {
this.removeEventListener('click', this.__onClick); this.removeEventListener('click', this.__onClick);
} }
__onClick = () => {
if (this.disabled) {
return;
}
const parentForm = /** @type {unknown} */ (this.__parentFormGroup);
if (parentForm && /** @type {ChoiceGroupHost} */ (parentForm).multipleChoice) {
this.checked = !this.checked;
} else {
this.checked = true;
}
};
} }

View file

@ -3,9 +3,6 @@ import { FormRegistrarPortalMixin } from '@lion/form-core';
/** /**
* LionOptions * LionOptions
*
* @customElement lion-options
* @extends {LitElement}
*/ */
export class LionOptions extends FormRegistrarPortalMixin(LitElement) { export class LionOptions extends FormRegistrarPortalMixin(LitElement) {
static get properties() { static get properties() {

View file

@ -0,0 +1,697 @@
import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/form-core';
import { css, html, dedupeMixin, ScopedElementsMixin, SlotMixin } from '@lion/core';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import { LionOptions } from './LionOptions.js';
// TODO: extract ListNavigationWithActiveDescendantMixin that can be reused in [role="menu"]
// having children with [role="menuitem|menuitemcheckbox|menuitemradio|option"] and
// list items that can be found via MutationObserver or registration (.formElements)
/**
* @typedef {import('./LionOption').LionOption} LionOption
* @typedef {import('../types/ListboxMixinTypes').ListboxMixin} ListboxMixin
* @typedef {import('../types/ListboxMixinTypes').ListboxHost} ListboxHost
* @typedef {import('@lion/form-core/types/registration/FormRegistrarPortalMixinTypes').FormRegistrarPortalHost} FormRegistrarPortalHost
*/
function uuid() {
return Math.random().toString(36).substr(2, 10);
}
/**
* @param {HTMLElement} container
* @param {HTMLElement} element
* @param {Boolean} [partial]
*/
function isInView(container, element, partial = false) {
const cTop = container.scrollTop;
const cBottom = cTop + container.clientHeight;
const eTop = element.offsetTop;
const eBottom = eTop + element.clientHeight;
const isTotal = eTop >= cTop && eBottom <= cBottom;
let isPartial;
if (partial === true) {
isPartial = (eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom);
} else if (typeof partial === 'number') {
if (eTop < cTop && eBottom > cTop) {
isPartial = ((eBottom - cTop) * 100) / element.clientHeight > partial;
} else if (eBottom > cBottom && eTop < cBottom) {
isPartial = ((cBottom - eTop) * 100) / element.clientHeight > partial;
}
}
return isTotal || isPartial;
}
/**
* @type {ListboxMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const ListboxMixinImplementation = superclass =>
class ListboxMixin extends FormControlMixin(
ScopedElementsMixin(ChoiceGroupMixin(SlotMixin(FormRegistrarMixin(superclass)))),
) {
static get properties() {
return {
orientation: String,
selectionFollowsFocus: {
type: Boolean,
attribute: 'selection-follows-focus',
},
rotateKeyboardNavigation: {
type: Boolean,
attribute: 'rotate-keyboard-navigation',
},
hasNoDefaultSelected: {
type: Boolean,
reflect: true,
attribute: 'has-no-default-selected',
},
};
}
static get styles() {
return [
css`
:host {
display: block;
}
:host([hidden]) {
display: none;
}
:host([disabled]) {
color: #adadad;
}
:host([orientation='horizontal']) ::slotted([role='listbox']) {
display: flex;
}
`,
];
}
static get scopedElements() {
return {
...super.scopedElements,
'lion-options': LionOptions,
};
}
get slots() {
return {
...super.slots,
input: () => {
const lionOptions = /** @type {HTMLElement & FormRegistrarPortalHost} */ (document.createElement(
ListboxMixin.getScopedTagName('lion-options'),
));
lionOptions.registrationTarget = this;
return lionOptions;
},
};
}
get _listboxNode() {
return /** @type {LionOptions} */ (this._inputNode);
}
get _listboxActiveDescendantNode() {
return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`);
}
get serializedValue() {
return this.modelValue;
}
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
// If you override one, gotta override the other, they go in pairs.
set serializedValue(value) {
super.serializedValue = value;
}
/**
* @type {number | number[]}
*/
get checkedIndex() {
const options = this.formElements;
if (!this.multipleChoice) {
return options.indexOf(options.find(o => o.checked));
}
return options.filter(o => o.checked).map(o => options.indexOf(o));
}
/**
* @deprecated
* This setter exists for backwards compatibility of single choice groups.
* A setter api would be confusing for a multipleChoice group. Use `setCheckedIndex` instead.
* @param {number} index
*/
set checkedIndex(index) {
this.setCheckedIndex(index);
}
/**
* When `multipleChoice` is false, will toggle, else will check provided index
* @param {Number} index
*/
setCheckedIndex(index) {
if (this.formElements[index]) {
if (!this.multipleChoice) {
this.formElements[index].checked = true;
} else {
this.formElements[index].checked = !this.formElements[index].checked;
// __onChildCheckedChanged, which also responds to programmatic (model)value changes
// of children, will do the rest
}
}
}
get activeIndex() {
return this.formElements.findIndex(el => el.active === true);
}
get _scrollTargetNode() {
return this._listboxNode;
}
set activeIndex(index) {
if (this.formElements[index]) {
const el = this.formElements[index];
el.active = true;
if (!isInView(this._scrollTargetNode, el)) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
constructor() {
super();
// this.disabled = false;
/**
* When setting this to true, on initial render, no option will be selected.
* It it advisable to override `_noSelectionTemplate` method in the select-invoker
* to render some kind of placeholder initially
*/
this.hasNoDefaultSelected = false;
/**
* Informs screen reader and affects keyboard navigation.
* By default 'vertical'
*/
this.orientation = 'vertical';
/**
* Will give first option active state when navigated to the next option from
* last option.
*/
this.rotateKeyboardNavigation = false;
/**
* When true, will synchronize activedescendant and selected element on
* arrow key navigation.
* This behavior can usually be seen on <select> on the Windows platform.
* Note that this behavior cannot be used when multiple-choice is true.
* See: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus
*/
this.selectionFollowsFocus = false;
/** @type {number | null} */
this._listboxActiveDescendant = null;
this.__hasInitialSelectedFormElement = false;
this._repropagationRole = 'choice-group'; // configures FormControlMixin
/** @type {EventListener} */
this._listboxOnKeyDown = this._listboxOnKeyDown.bind(this);
/** @type {EventListener} */
this._listboxOnClick = this._listboxOnClick.bind(this);
/** @type {EventListener} */
this._listboxOnKeyUp = this._listboxOnKeyUp.bind(this);
/** @type {EventListener} */
this._onChildActiveChanged = this._onChildActiveChanged.bind(this);
/** @type {EventListener} */
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
}
connectedCallback() {
if (this._listboxNode) {
// if there is none yet, it will be supplied via static get slots
this._listboxNode.registrationTarget = this;
}
super.connectedCallback();
this.__setupListboxNode();
this.__setupEventListeners();
this.registrationComplete.then(() => {
this.__initInteractionStates();
});
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__moveOptionsToListboxNode();
}
/**
* Moves options put in regulat slot to slot wiht role=listbox
*/
__moveOptionsToListboxNode() {
const slot = /** @type {HTMLSlotElement} */ (
/** @type {ShadowRoot} */ (this.shadowRoot).getElementById('options-outlet')
);
if (slot) {
slot.addEventListener('slotchange', () => {
slot.assignedNodes().forEach(node => {
this._listboxNode.appendChild(node);
});
});
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._teardownListboxNode();
this.__teardownEventListeners();
}
/**
* In the select disabled options are still going to a possible value for example
* when prefilling or programmatically setting it.
*
* @override
*/
_getCheckedElements() {
return this.formElements.filter(el => el.checked);
}
__initInteractionStates() {
this.initInteractionState();
}
// TODO: inherit from FormControl ?
get _inputNode() {
return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (this.formElements.length === 1) {
this.singleOption = true;
// this._invokerNode.singleOption = true;
}
if (changedProperties.has('disabled')) {
if (this.disabled) {
// this._invokerNode.makeRequestToBeDisabled();
this.__requestOptionsToBeDisabled();
} else {
// this._invokerNode.retractRequestToBeDisabled();
this.__retractRequestOptionsToBeDisabled();
}
}
}
/**
* @override
*/
// eslint-disable-next-line
_inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="input"></slot>
<slot id="options-outlet"></slot>
</div>
`;
}
/**
* Overrides FormRegistrar adding to make sure children have specific default states when added
*
* @override
* @param {LionOption} child
* @param {Number} indexToInsertAt
*/
// @ts-expect-error
addFormElement(child, indexToInsertAt) {
// @ts-expect-error
super.addFormElement(/** @type {FormControl} */ child, indexToInsertAt);
// we need to adjust the elements being registered
/* eslint-disable no-param-reassign */
child.id = child.id || `${this.localName}-option-${uuid()}`;
if (this.disabled) {
child.makeRequestToBeDisabled();
}
// the first elements checked by default
if (
!this.hasNoDefaultSelected &&
!this.__hasInitialSelectedFormElement &&
(!child.disabled || this.disabled)
) {
child.active = true;
child.checked = true;
this.__hasInitialSelectedFormElement = true;
}
// TODO: small perf improvement could be made if logic below would be scheduled to next update,
// so it occurs once for all options
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
this.formElements.forEach((el, idx) => {
el.setAttribute('aria-posinset', idx + 1);
});
this.__proxyChildModelValueChanged(
/** @type {Event & { target: LionOption; }} */ ({ target: child }),
);
this.resetInteractionState();
/* eslint-enable no-param-reassign */
}
__setupEventListeners() {
this._listboxNode.addEventListener(
'active-changed',
/** @type {EventListener} */ (this._onChildActiveChanged),
);
this._listboxNode.addEventListener(
'model-value-changed',
/** @type {EventListener} */ (this.__proxyChildModelValueChanged),
);
// this._listboxNode.addEventListener('checked-changed', this.__onChildCheckedChanged);
}
__teardownEventListeners() {
this._listboxNode.removeEventListener(
'active-changed',
/** @type {EventListener} */ (this._onChildActiveChanged),
);
this._listboxNode.removeEventListener(
'model-value-changed',
/** @type {EventListener} */ (this.__proxyChildModelValueChanged),
);
}
/**
* @param {Event & { target: LionOption }} ev
*/
_onChildActiveChanged({ target }) {
if (target.active === true) {
this.formElements.forEach(formElement => {
if (formElement !== target) {
// eslint-disable-next-line no-param-reassign
formElement.active = false;
}
});
this._listboxNode.setAttribute('aria-activedescendant', target.id);
}
}
/**
* @param {Event & { target: LionOption }} cfgOrEvent
*/
__onChildCheckedChanged(cfgOrEvent) {
const { target } = cfgOrEvent;
if (cfgOrEvent.stopPropagation) {
cfgOrEvent.stopPropagation();
}
if (target.checked) {
if (!this.multipleChoice) {
this.formElements.forEach(formElement => {
if (formElement !== target) {
// eslint-disable-next-line no-param-reassign
formElement.checked = false;
}
});
}
}
}
/**
* // TODO: add to choiceGroup
* @param {string} attribute
* @param {number} value
*/
__setAttributeForAllFormElements(attribute, value) {
this.formElements.forEach(formElement => {
formElement.setAttribute(attribute, value);
});
}
/**
* @param {Event & { target: LionOption; }} ev
*/
__proxyChildModelValueChanged(ev) {
// We need to redispatch the model-value-changed event on 'this', so it will
// align with FormControl.__repropagateChildrenValues method. Also, this makes
// it act like a portal, in case the listbox is put in a modal overlay on body level.
if (ev.stopPropagation) {
ev.stopPropagation();
}
this.__onChildCheckedChanged(ev);
this.requestUpdate('modelValue');
this.dispatchEvent(
new CustomEvent('model-value-changed', { detail: { element: ev.target } }),
);
}
/**
* @param {number} currentIndex
* @param {number} offset
*/
__getNextOption(currentIndex, offset) {
/**
* @param {number} i
*/
const until = i => (offset === 1 ? i < this.formElements.length : i >= 0);
for (let i = currentIndex + offset; until(i); i += offset) {
if (this.formElements[i] && !this.formElements[i].disabled) {
return i;
}
}
if (this.rotateKeyboardNavigation) {
const startIndex = offset === -1 ? this.formElements.length - 1 : 0;
for (let i = startIndex; until(i); i += 1) {
if (this.formElements[i] && !this.formElements[i].disabled) {
return i;
}
}
}
return currentIndex;
}
/**
* @param {number} currentIndex
* @param {number} [offset=1]
*/
_getNextEnabledOption(currentIndex, offset = 1) {
return this.__getNextOption(currentIndex, offset);
}
/**
* @param {number} currentIndex
* @param {number} [offset=-1]
*/
_getPreviousEnabledOption(currentIndex, offset = -1) {
return this.__getNextOption(currentIndex, offset);
}
/**
* @desc
* Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
* an item.
*
* @param {KeyboardEvent} ev - the keydown event object
*/
_listboxOnKeyDown(ev) {
if (this.disabled) {
return;
}
const { key } = ev;
switch (key) {
case 'Enter':
case ' ':
ev.preventDefault();
this.setCheckedIndex(this.activeIndex);
break;
case 'ArrowUp':
ev.preventDefault();
if (this.orientation === 'vertical') {
this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
}
break;
case 'ArrowLeft':
ev.preventDefault();
if (this.orientation === 'horizontal') {
this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
}
break;
case 'ArrowDown':
ev.preventDefault();
if (this.orientation === 'vertical') {
this.activeIndex = this._getNextEnabledOption(this.activeIndex);
}
break;
case 'ArrowRight':
ev.preventDefault();
if (this.orientation === 'horizontal') {
this.activeIndex = this._getNextEnabledOption(this.activeIndex);
}
break;
case 'Home':
ev.preventDefault();
this.activeIndex = this._getNextEnabledOption(0, 0);
break;
case 'End':
ev.preventDefault();
this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0);
break;
/* no default */
}
const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End'];
if (keys.includes(key) && this.selectionFollowsFocus && !this.multipleChoice) {
this.setCheckedIndex(this.activeIndex);
}
}
// TODO: move to ChoiceGroupMixin?
__requestOptionsToBeDisabled() {
this.formElements.forEach(el => {
if (el.makeRequestToBeDisabled) {
el.makeRequestToBeDisabled();
}
});
}
__retractRequestOptionsToBeDisabled() {
this.formElements.forEach(el => {
if (el.retractRequestToBeDisabled) {
el.retractRequestToBeDisabled();
}
});
}
/**
* For ShadyDom the listboxNode is available right from the start so we can add those events
* immediately.
* For native ShadowDom the select gets render before the listboxNode is available so we
* will add an event to the slotchange and add the events once available.
*/
__setupListboxNode() {
if (this._listboxNode) {
this._setupListboxNodeInteractions();
} else {
const inputSlot = /** @type {ShadowRoot} */ (this.shadowRoot).querySelector(
'slot[name=input]',
);
if (inputSlot) {
inputSlot.addEventListener('slotchange', () => {
this._setupListboxNodeInteractions();
});
}
}
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
}
/**
* @overridable
* @param {MouseEvent} ev
*/
// eslint-disable-next-line class-methods-use-this, no-unused-vars
_listboxOnClick(ev) {
const option = /** @type {HTMLElement} */ (ev.target).closest('[role=option]');
const foundIndex = this.formElements.indexOf(option);
if (foundIndex > -1) {
this.activIndex = foundIndex;
}
}
/**
* @overridable
* @param {KeyboardEvent} ev
*/
// eslint-disable-next-line class-methods-use-this, no-unused-vars
_listboxOnKeyUp(ev) {
if (this.disabled) {
return;
}
const { key } = ev;
// eslint-disable-next-line default-case
switch (key) {
case 'ArrowUp':
case 'ArrowDown':
case 'Home':
case 'End':
case ' ':
case 'Enter':
ev.preventDefault();
}
}
_setupListboxNodeInteractions() {
this._listboxNode.setAttribute('role', 'listbox');
this._listboxNode.setAttribute('aria-orientation', this.orientation);
this._listboxNode.setAttribute('aria-multiselectable', `${this.multipleChoice}`);
this._listboxNode.setAttribute('tabindex', '0');
this._listboxNode.addEventListener('click', this._listboxOnClick);
this._listboxNode.addEventListener('keyup', this._listboxOnKeyUp);
this._listboxNode.addEventListener('keydown', this._listboxOnKeyDown);
}
_teardownListboxNode() {
if (this._listboxNode) {
this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
this._listboxNode.removeEventListener('click', this._listboxOnClick);
this._listboxNode.removeEventListener('keyup', this._listboxOnKeyUp);
}
}
/**
* @param {KeyboardEvent} ev
*/
__preventScrollingWithArrowKeys(ev) {
if (this.disabled) {
return;
}
const { key } = ev;
switch (key) {
case 'ArrowUp':
case 'ArrowDown':
case 'Home':
case 'End':
ev.preventDefault();
/* no default */
}
}
// TODO: move to FormControl / ValidateMixin?
/**
* @param {string} value
*/
set fieldName(value) {
this.__fieldName = value;
}
get fieldName() {
const label =
this.label ||
(this.querySelector('[slot=label]') && this.querySelector('[slot=label]')?.textContent);
return this.__fieldName || label || this.name;
}
};
export const ListboxMixin = dedupeMixin(ListboxMixinImplementation);

View file

@ -0,0 +1,799 @@
import { Required } from '@lion/form-core';
import { expect, html, fixture, unsafeStatic } from '@open-wc/testing';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '../lion-listbox.js';
/**
* @param { {tagString:string, optionTagString:string} } [customConfig]
*/
export function runListboxMixinSuite(customConfig = {}) {
const cfg = {
tagString: 'lion-listbox',
optionTagString: 'lion-option',
...customConfig,
};
const tag = unsafeStatic(cfg.tagString);
const optionTag = unsafeStatic(cfg.optionTagString);
describe('ListboxMixin', () => {
it('has a single modelValue representing the currently checked option', async () => {
const el = await fixture(html`
<${tag} name="foo">
<lion-options slot="input">
<${optionTag} .choiceValue=${10} checked>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.modelValue).to.equal(10);
});
it('automatically sets the name attribute of child checkboxes to its own name', async () => {
const el = await fixture(html`
<${tag} name="foo">
<lion-options slot="input">
<${optionTag} .choiceValue=${10} checked>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.formElements[0].name).to.equal('foo');
expect(el.formElements[1].name).to.equal('foo');
const validChild = await fixture(
html` <${optionTag} .choiceValue=${30}>Item 3</${optionTag}> `,
);
el.appendChild(validChild);
expect(el.formElements[2].name).to.equal('foo');
});
it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
const el = await fixture(html`
<${tag} name="foo">
<lion-options slot="input">
<${optionTag} .choiceValue=${10} checked>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
const invalidChild = await fixture(
html` <${optionTag} .modelValue=${'Lara'}></${optionTag}> `,
);
expect(() => {
el.addFormElement(invalidChild);
}).to.throw(
`The ${cfg.tagString} name="foo" does not allow to register lion-option with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }`,
);
});
it('throws if a child element with a different name than the group tries to register', async () => {
const el = await fixture(html`
<${tag} name="gender">
<lion-options slot="input">
<${optionTag} .choiceValue=${'female'} checked></${optionTag}>
<${optionTag} .choiceValue=${'other'}></${optionTag}>
</lion-options>
</${tag}>
`);
const invalidChild = await fixture(html`
<${optionTag} name="foo" .choiceValue=${'male'}></${optionTag}>
`);
expect(() => {
el.addFormElement(invalidChild);
}).to.throw(
`The ${cfg.tagString} name="gender" does not allow to register lion-option with custom names (name="foo" given)`,
);
});
it('can set initial modelValue on creation', async () => {
const el = await fixture(html`
<${tag} name="gender" .modelValue=${'other'}>
<lion-options slot="input">
<${optionTag} .choiceValue=${'male'}></${optionTag}>
<${optionTag} .choiceValue=${'female'}></${optionTag}>
<${optionTag} .choiceValue=${'other'}></${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.modelValue).to.equal('other');
expect(el.formElements[2].checked).to.be.true;
});
it(`has a fieldName based on the label`, async () => {
const el1 = await fixture(html`
<${tag} label="foo"><lion-options slot="input"></lion-options></${tag}>
`);
expect(el1.fieldName).to.equal(el1._labelNode.textContent);
const el2 = await fixture(html`
<${tag}>
<label slot="label">bar</label><lion-options slot="input"></lion-options>
</${tag}>
`);
expect(el2.fieldName).to.equal(el2._labelNode.textContent);
});
it(`has a fieldName based on the name if no label exists`, async () => {
const el = await fixture(html`
<${tag} name="foo"><lion-options slot="input"></lion-options></${tag}>
`);
expect(el.fieldName).to.equal(el.name);
});
it(`can override fieldName`, async () => {
const el = await fixture(html`
<${tag} label="foo" .fieldName="${'bar'}"
><lion-options slot="input"></lion-options
></${tag}>
`);
expect(el.__fieldName).to.equal(el.fieldName);
});
it('does not have a tabindex', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input"></lion-options>
</${tag}>
`);
expect(el.hasAttribute('tabindex')).to.be.false;
});
it('delegates the name attribute to its children options', async () => {
const el = await fixture(html`
<${tag} name="foo">
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
const optOne = el.querySelectorAll('lion-option')[0];
const optTwo = el.querySelectorAll('lion-option')[1];
expect(optOne.name).to.equal('foo');
expect(optTwo.name).to.equal('foo');
});
it('supports validation', async () => {
const el = await fixture(html`
<${tag}
id="color"
name="color"
label="Favorite color"
.validators="${[new Required()]}"
>
<lion-options slot="input">
<${optionTag} .choiceValue=${null}>select a color</${optionTag}>
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'hotpink'} disabled>Hotpink</${optionTag}>
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.hasFeedbackFor.includes('error')).to.be.true;
expect(el.showsFeedbackFor.includes('error')).to.be.false;
// test submitted prop explicitly, since we dont extend field, we add the prop manually
el.submitted = true;
await el.updateComplete;
expect(el.showsFeedbackFor.includes('error')).to.be.true;
el._listboxNode.children[1].checked = true;
await el.updateComplete;
expect(el.hasFeedbackFor.includes('error')).to.be.false;
expect(el.showsFeedbackFor.includes('error')).to.be.false;
el._listboxNode.children[0].checked = true;
await el.updateComplete;
expect(el.hasFeedbackFor.includes('error')).to.be.true;
expect(el.showsFeedbackFor.includes('error')).to.be.true;
});
it('supports having no default selection initially', async () => {
const el = await fixture(html`
<${tag} id="color" name="color" label="Favorite color" has-no-default-selected>
<lion-options slot="input">
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'hotpink'}>Hotpink</${optionTag}>
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.selectedElement).to.be.undefined;
expect(el.modelValue).to.equal('');
});
it('supports changing the selection through serializedValue setter', async () => {
const el = await fixture(html`
<${tag} id="color" name="color" label="Favorite color">
<lion-options slot="input">
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'hotpink'}>Hotpink</${optionTag}>
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.checkedIndex).to.equal(0);
expect(el.serializedValue).to.equal('red');
el.serializedValue = 'hotpink';
expect(el.checkedIndex).to.equal(1);
expect(el.serializedValue).to.equal('hotpink');
});
describe('Accessibility', () => {
it('is accessible when closed', async () => {
const el = await fixture(html`
<${tag} label="age">
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
await expect(el).to.be.accessible();
});
it('is accessible when opened', async () => {
const el = await fixture(html`
<${tag} label="age">
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
el.opened = true;
await el.updateComplete;
await el.updateComplete; // need 2 awaits as overlay.show is an async function
await expect(el).to.be.accessible();
});
});
describe('Use cases', () => {
it('works for complex array data', async () => {
const objs = [
{ type: 'mastercard', label: 'Master Card', amount: 12000, active: true },
{ type: 'visacard', label: 'Visa Card', amount: 0, active: false },
];
const el = await fixture(html`
<${tag} label="Favorite color" name="color">
<lion-options slot="input">
${objs.map(
obj => html`
<${optionTag} .modelValue=${{ value: obj, checked: false }}
>${obj.label}</${optionTag}
>
`,
)}
</lion-options>
</${tag}>
`);
expect(el.modelValue).to.deep.equal({
type: 'mastercard',
label: 'Master Card',
amount: 12000,
active: true,
});
el.checkedIndex = 1;
expect(el.modelValue).to.deep.equal({
type: 'visacard',
label: 'Visa Card',
amount: 0,
active: false,
});
});
});
describe('Instantiation methods', () => {
it('can be instantiated via "document.createElement"', async () => {
let properlyInstantiated = false;
try {
const el = document.createElement('lion-listbox');
const optionsEl = document.createElement('lion-options');
optionsEl.slot = 'input';
const optionEl = document.createElement('lion-option');
optionsEl.appendChild(optionEl);
el.appendChild(optionsEl);
properlyInstantiated = true;
} catch (e) {
throw Error(e);
}
expect(properlyInstantiated).to.be.true;
});
});
});
describe('lion-listbox interactions', () => {
describe('values', () => {
it('registers options', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.formElements.length).to.equal(2);
expect(el.formElements).to.eql([
el.querySelectorAll('lion-option')[0],
el.querySelectorAll('lion-option')[1],
]);
});
it('has the first element by default checked and active', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.querySelector('lion-option').checked).to.be.true;
expect(el.querySelector('lion-option').active).to.be.true;
expect(el.modelValue).to.equal(10);
expect(el.checkedIndex).to.equal(0);
expect(el.activeIndex).to.equal(0);
});
it('allows null choiceValue', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${null}>Please select value</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.modelValue).to.be.null;
});
it('has the checked option as modelValue', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20} checked>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.modelValue).to.equal(20);
});
it('has an activeIndex', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.activeIndex).to.equal(0);
el.querySelectorAll('lion-option')[1].active = true;
expect(el.querySelectorAll('lion-option')[0].active).to.be.false;
expect(el.activeIndex).to.equal(1);
});
});
describe('Keyboard navigation', () => {
it('does not allow to navigate above the first or below the last option', async () => {
const el = await fixture(html`
<${tag} opened>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
</lion-options>
</${tag}>
`);
expect(() => {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
}).to.not.throw();
expect(el.checkedIndex).to.equal(0);
expect(el.activeIndex).to.equal(0);
});
// TODO: nice to have
it.skip('selects a value with single [character] key', async () => {
const el = await fixture(html`
<${tag} opened>
<lion-options slot="input" name="foo">
<${optionTag} .choiceValue=${'a'}>A</${optionTag}>
<${optionTag} .choiceValue=${'b'}>B</${optionTag}>
<${optionTag} .choiceValue=${'c'}>C</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.choiceValue).to.equal('a');
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' }));
expect(el.choiceValue).to.equal('c');
});
it.skip('selects a value with multiple [character] keys', async () => {
const el = await fixture(html`
<${tag} opened>
<lion-options slot="input" name="foo">
<${optionTag} .choiceValue=${'bar'}>Bar</${optionTag}>
<${optionTag} .choiceValue=${'far'}>Far</${optionTag}>
<${optionTag} .choiceValue=${'foo'}>Foo</${optionTag}>
</lion-options>
</${tag}>
`);
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F' }));
expect(el.choiceValue).to.equal('far');
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' }));
expect(el.choiceValue).to.equal('foo');
});
});
describe('Keyboard navigation Mac', () => {
it('navigates through open list with [ArrowDown] [ArrowUp] keys activates the option', async () => {
const el = await fixture(html`
<${tag} opened interaction-mode="mac">
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.activeIndex).to.equal(0);
expect(el.checkedIndex).to.equal(0);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.activeIndex).to.equal(1);
expect(el.checkedIndex).to.equal(0);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(el.activeIndex).to.equal(0);
expect(el.checkedIndex).to.equal(0);
});
});
describe('Disabled', () => {
it('still has a checked value', async () => {
const el = await fixture(html`
<${tag} disabled>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.modelValue).to.equal(10);
});
it('cannot be navigated with keyboard if disabled', async () => {
const el = await fixture(html`
<${tag} disabled>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.modelValue).to.equal(10);
});
it('skips disabled options while navigating through list with [ArrowDown] [ArrowUp] keys', async () => {
const el = await fixture(html`
<${tag} opened>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20} disabled>Item 2</${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
</lion-options>
</${tag}>
`);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.activeIndex).to.equal(2);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(el.activeIndex).to.equal(0);
});
// flaky test
it.skip('skips disabled options while navigates to first and last option with [Home] and [End] keys', async () => {
const el = await fixture(html`
<${tag} opened>
<lion-options slot="input" name="foo">
<${optionTag} .choiceValue=${10} disabled>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
<${optionTag} .choiceValue=${30} checked>Item 3</${optionTag}>
<${optionTag} .choiceValue=${40} disabled>Item 4</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.activeIndex).to.equal(2);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
expect(el.activeIndex).to.equal(2);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
expect(el.activeIndex).to.equal(1);
});
it('checks the first enabled option', async () => {
const el = await fixture(html`
<${tag} opened>
<lion-options slot="input">
<${optionTag} .choiceValue=${10} disabled>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.activeIndex).to.equal(1);
expect(el.checkedIndex).to.equal(1);
});
it('sync its disabled state to all options', async () => {
const el = await fixture(html`
<${tag} opened>
<lion-options slot="input">
<${optionTag} .choiceValue=${10} disabled>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
const options = [...el.querySelectorAll('lion-option')];
el.disabled = true;
await el.updateComplete;
expect(options[0].disabled).to.be.true;
expect(options[1].disabled).to.be.true;
el.disabled = false;
await el.updateComplete;
expect(options[0].disabled).to.be.true;
expect(options[1].disabled).to.be.false;
});
it('can be enabled (incl. its options) even if it starts as disabled', async () => {
const el = await fixture(html`
<${tag} disabled>
<lion-options slot="input">
<${optionTag} .choiceValue=${10} disabled>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
const options = [...el.querySelectorAll('lion-option')];
expect(options[0].disabled).to.be.true;
expect(options[1].disabled).to.be.true;
el.disabled = false;
await el.updateComplete;
expect(options[0].disabled).to.be.true;
expect(options[1].disabled).to.be.false;
});
});
describe('Programmatic interaction', () => {
it('can set active state', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20} id="myId">Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
const opt = el.querySelectorAll('lion-option')[1];
opt.active = true;
expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('myId');
});
it('can set checked state', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
const option = el.querySelectorAll('lion-option')[1];
option.checked = true;
expect(el.modelValue).to.equal(20);
});
it('does not allow to set checkedIndex or activeIndex to be out of bound', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
</lion-options>
</${tag}>
`);
expect(() => {
el.activeIndex = -1;
el.activeIndex = 1;
el.checkedIndex = -1;
el.checkedIndex = 1;
}).to.not.throw();
expect(el.checkedIndex).to.equal(0);
expect(el.activeIndex).to.equal(0);
});
it('unsets checked on other options when option becomes checked', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
const options = el.querySelectorAll('lion-option');
expect(options[0].checked).to.be.true;
options[1].checked = true;
expect(options[0].checked).to.be.false;
});
it('unsets active on other options when option becomes active', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
const options = el.querySelectorAll('lion-option');
expect(options[0].active).to.be.true;
options[1].active = true;
expect(options[0].active).to.be.false;
});
});
describe('Interaction states', () => {
it('becomes dirty if value changed once', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.dirty).to.be.false;
el.modelValue = 20;
expect(el.dirty).to.be.true;
});
it('is prefilled if there is a value on init', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.prefilled).to.be.true;
const elEmpty = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${null}>Please select a value</${optionTag}>
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
</lion-options>
</${tag}>
`);
expect(elEmpty.prefilled).to.be.false;
});
});
describe('Validation', () => {
it('can be required', async () => {
const el = await fixture(html`
<${tag} .validators=${[new Required()]}>
<lion-options slot="input">
<${optionTag} .choiceValue=${null}>Please select a value</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).to.have.a.property('Required');
el.modelValue = 20;
expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).not.to.have.a.property('Required');
});
});
describe('Accessibility', () => {
it('creates unique ids for all children', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input" name="foo">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20} selected>Item 2</${optionTag}>
<${optionTag} .choiceValue=${30} id="predefined">Item 3</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el.querySelectorAll('lion-option')[0].id).to.exist;
expect(el.querySelectorAll('lion-option')[1].id).to.exist;
expect(el.querySelectorAll('lion-option')[2].id).to.equal('predefined');
});
it('has a reference to the selected option', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input" name="foo">
<${optionTag} .choiceValue=${10} id="first">Item 1</${optionTag}>
<${optionTag} .choiceValue=${20} checked id="second">Item 2</${optionTag}>
</lion-options>
</${tag}>
`);
expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('first');
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('second');
});
it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input" name="foo">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
</lion-options>
</${tag}>
`);
const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
optionEls.forEach(optionEl => {
expect(optionEl.getAttribute('aria-setsize')).to.equal('3');
});
});
it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => {
const el = await fixture(html`
<${tag}>
<lion-options slot="input">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
</lion-options>
</${tag}>
`);
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,3 @@
import { runListboxMixinSuite } from '../test-suites/ListboxMixin.suite.js';
runListboxMixinSuite();

View file

@ -1,35 +1,43 @@
import { expect, fixture, html } from '@open-wc/testing'; import { expect, fixture, html } from '@open-wc/testing';
import sinon from 'sinon'; import sinon from 'sinon';
// eslint-disable-next-line no-unused-vars
import { LionOption } from '../src/LionOption.js';
import '../lion-option.js'; import '../lion-option.js';
describe('lion-option', () => { describe('lion-option', () => {
describe('Values', () => { describe('Values', () => {
it('has a modelValue', async () => { it('has a modelValue', async () => {
const el = await fixture(html`<lion-option .choiceValue=${10}></lion-option>`); const el = /** @type {LionOption} */ (await fixture(
html`<lion-option .choiceValue=${10}></lion-option>`,
));
expect(el.modelValue).to.deep.equal({ value: 10, checked: false }); expect(el.modelValue).to.deep.equal({ value: 10, checked: false });
}); });
it('can be checked', async () => { it('can be checked', async () => {
const el = await fixture(html`<lion-option .choiceValue=${10} checked></lion-option>`); const el = /** @type {LionOption} */ (await fixture(
html`<lion-option .choiceValue=${10} checked></lion-option>`,
));
expect(el.modelValue).to.deep.equal({ value: 10, checked: true }); expect(el.modelValue).to.deep.equal({ value: 10, checked: true });
}); });
it('is hidden when attribute hidden is true', async () => { it('is hidden when attribute hidden is true', async () => {
const el = await fixture(html`<lion-option .choiceValue=${10} hidden></lion-option>`); const el = /** @type {LionOption} */ (await fixture(
html`<lion-option .choiceValue=${10} hidden></lion-option>`,
));
expect(el).not.to.be.displayed; expect(el).not.to.be.displayed;
}); });
}); });
describe('Accessibility', () => { describe('Accessibility', () => {
it('has the "option" role', async () => { it('has the "option" role', async () => {
const el = await fixture(html`<lion-option></lion-option>`); const el = /** @type {LionOption} */ (await fixture(html`<lion-option></lion-option>`));
expect(el.getAttribute('role')).to.equal('option'); expect(el.getAttribute('role')).to.equal('option');
}); });
it('has "aria-selected" attribute when checked', async () => { it('has "aria-selected" attribute when checked', async () => {
const el = await fixture(html` const el = /** @type {LionOption} */ (await fixture(html`
<lion-option .choiceValue=${10} checked>Item 1</lion-option> <lion-option .choiceValue=${10} checked>Item 1</lion-option>
`); `));
expect(el.getAttribute('aria-selected')).to.equal('true'); expect(el.getAttribute('aria-selected')).to.equal('true');
el.checked = false; el.checked = false;
@ -41,9 +49,9 @@ describe('lion-option', () => {
}); });
it('asynchronously adds the attributes "aria-disabled" and "disabled" when disabled', async () => { it('asynchronously adds the attributes "aria-disabled" and "disabled" when disabled', async () => {
const el = await fixture(html` const el = /** @type {LionOption} */ (await fixture(html`
<lion-option .choiceValue=${10} disabled>Item 1</lion-option> <lion-option .choiceValue=${10} disabled>Item 1</lion-option>
`); `));
expect(el.getAttribute('aria-disabled')).to.equal('true'); expect(el.getAttribute('aria-disabled')).to.equal('true');
expect(el.hasAttribute('disabled')).to.be.true; expect(el.hasAttribute('disabled')).to.be.true;
@ -59,7 +67,9 @@ describe('lion-option', () => {
describe('State reflection', () => { describe('State reflection', () => {
it('asynchronously adds the attribute "active" when active', async () => { it('asynchronously adds the attribute "active" when active', async () => {
const el = await fixture(html`<lion-option .choiceValue=${10}></lion-option>`); const el = /** @type {LionOption} */ (await fixture(
html`<lion-option .choiceValue=${10}></lion-option>`,
));
expect(el.active).to.equal(false); expect(el.active).to.equal(false);
expect(el.hasAttribute('active')).to.be.false; expect(el.hasAttribute('active')).to.be.false;
@ -77,7 +87,9 @@ describe('lion-option', () => {
}); });
it('does become checked on [click]', async () => { it('does become checked on [click]', async () => {
const el = await fixture(html`<lion-option .choiceValue=${10}></lion-option>`); const el = /** @type {LionOption} */ (await fixture(
html`<lion-option .choiceValue=${10}></lion-option>`,
));
expect(el.checked).to.be.false; expect(el.checked).to.be.false;
el.click(); el.click();
await el.updateComplete; await el.updateComplete;
@ -86,9 +98,12 @@ describe('lion-option', () => {
it('fires active-changed event', async () => { it('fires active-changed event', async () => {
const activeSpy = sinon.spy(); const activeSpy = sinon.spy();
const el = await fixture(html` const el = /** @type {LionOption} */ (await fixture(html`
<lion-option .choiceValue=${10} @active-changed="${activeSpy}"></lion-option> <lion-option
`); .choiceValue=${10}
@active-changed="${/** @type {function} */ (activeSpy)}"
></lion-option>
`));
expect(activeSpy.callCount).to.equal(0); expect(activeSpy.callCount).to.equal(0);
el.active = true; el.active = true;
expect(activeSpy.callCount).to.equal(1); expect(activeSpy.callCount).to.equal(1);
@ -97,14 +112,18 @@ describe('lion-option', () => {
describe('Disabled', () => { describe('Disabled', () => {
it('does not becomes active on [mouseenter]', async () => { it('does not becomes active on [mouseenter]', async () => {
const el = await fixture(html`<lion-option .choiceValue=${10} disabled></lion-option>`); const el = /** @type {LionOption} */ (await fixture(
html`<lion-option .choiceValue=${10} disabled></lion-option>`,
));
expect(el.active).to.be.false; expect(el.active).to.be.false;
el.dispatchEvent(new Event('mouseenter')); el.dispatchEvent(new Event('mouseenter'));
expect(el.active).to.be.false; expect(el.active).to.be.false;
}); });
it('does not become checked on [click]', async () => { it('does not become checked on [click]', async () => {
const el = await fixture(html`<lion-option .choiceValue=${10} disabled></lion-option>`); const el = /** @type {LionOption} */ (await fixture(
html`<lion-option .choiceValue=${10} disabled></lion-option>`,
));
expect(el.checked).to.be.false; expect(el.checked).to.be.false;
el.click(); el.click();
await el.updateComplete; await el.updateComplete;
@ -112,9 +131,9 @@ describe('lion-option', () => {
}); });
it('does not become un-active on [mouseleave]', async () => { it('does not become un-active on [mouseleave]', async () => {
const el = await fixture(html` const el = /** @type {LionOption} */ (await fixture(html`
<lion-option .choiceValue=${10} active disabled></lion-option> <lion-option .choiceValue=${10} active disabled></lion-option>
`); `));
expect(el.active).to.be.true; expect(el.active).to.be.true;
el.dispatchEvent(new Event('mouseleave')); el.dispatchEvent(new Event('mouseleave'));
expect(el.active).to.be.true; expect(el.active).to.be.true;

View file

@ -1,13 +1,14 @@
import { expect, fixture, html } from '@open-wc/testing'; import { expect, fixture, html } from '@open-wc/testing';
// eslint-disable-next-line no-unused-vars
import { LionOptions } from '../src/LionOptions.js';
import '../lion-options.js'; import '../lion-options.js';
describe('lion-options', () => { describe('lion-options', () => {
it('should have role="listbox"', async () => { it('should have role="listbox"', async () => {
const registrationTargetEl = document.createElement('div'); const registrationTargetEl = document.createElement('div');
const el = await fixture(html` const el = /** @type {LionOptions} */ (await fixture(html`
<lion-options .registrationTarget=${registrationTargetEl}></lion-options> <lion-options .registrationTarget=${registrationTargetEl}></lion-options>
`); `));
expect(el.role).to.equal('listbox'); expect(el.role).to.equal('listbox');
}); });
}); });

View file

@ -0,0 +1,5 @@
import { ChoiceGroupHost } from '@lion/form-core/types/choice-group/ChoiceGroupMixinTypes';
export declare class LionOptionHost {
private __parentFormGroup: ChoiceGroupHost;
}

View file

@ -0,0 +1,83 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core';
import { SlotHost } from '@lion/core/types/SlotMixinTypes';
import { FormControlHost } from '@lion/form-core/types/FormControlMixinTypes';
import { FormRegistrarHost } from '@lion/form-core/types/registration/FormRegistrarMixinTypes';
import { ChoiceGroupHost } from '@lion/form-core/types/choice-group/ChoiceGroupMixinTypes';
import { LionOptions } from '../src/LionOptions.js';
import { LionOption } from '../src/LionOption.js';
export declare class ListboxHost {
/**
* When true, will synchronize activedescendant and selected element on
* arrow key navigation.
* This behavior can usually be seen on <select> on the Windows platform.
* Note that this behavior cannot be used when multiple-choice is true.
* See: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus
*/
public selectionFollowsFocus: Boolean;
/**
* Will give first option active state when navigated to the next option from
* last option.
*/
public rotateKeyboardNavigation: Boolean;
/**
* Informs screen reader and affects keyboard navigation.
* By default 'vertical'
*/
public orientation: 'vertical' | 'horizontal';
public hasNoDefaultSelected: boolean;
public singleOption: boolean;
// protected _invokerNode: HTMLElement;
public checkedIndex: number | number[];
public activeIndex: number;
public formElements: LionOption[];
public setCheckedIndex(index: number): void;
protected _scrollTargetNode: LionOptions;
protected _listboxNode: LionOptions;
private __setupListboxNode(): void;
protected _getPreviousEnabledOption(currentIndex: number, offset?: number): number;
protected _getNextEnabledOption(currentIndex: number, offset?: number): number;
protected _listboxOnKeyDown(ev: KeyboardEvent): void;
protected _listboxOnKeyUp(ev: KeyboardEvent): void;
protected _setupListboxNodeInteractions(): void;
protected _teardownListboxNode(): void;
protected _listboxOnClick(ev: MouseEvent): void;
protected _setupListboxInteractions(): void;
protected _onChildActiveChanged(ev: Event): void;
}
export declare function ListboxImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T &
Constructor<ListboxHost> &
ListboxHost &
Constructor<ChoiceGroupHost> &
typeof ChoiceGroupHost &
Constructor<SlotHost> &
typeof SlotHost &
Constructor<FormRegistrarHost> &
typeof FormRegistrarHost &
Constructor<FormControlHost> &
typeof FormControlHost;
export type ListboxMixin = typeof ListboxImplementation;

View file

@ -13,8 +13,8 @@ import { html } from '@lion/core';
import { Required } from '@lion/form-core'; import { Required } from '@lion/form-core';
import { loadDefaultFeedbackMessages } from '@lion/validate-messages'; import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
import './lion-option.js'; import '@lion/listbox/lion-option.js';
import './lion-options.js'; import '@lion/listbox/lion-options.js';
import './lion-select-rich.js'; import './lion-select-rich.js';
export default { export default {
@ -55,8 +55,8 @@ npm i --save @lion/select-rich
import { LionSelectRich, LionOptions, LionOption } from '@lion/select-rich'; import { LionSelectRich, LionOptions, LionOption } from '@lion/select-rich';
// or // or
import '@lion/select-rich/lion-select-rich.js'; import '@lion/select-rich/lion-select-rich.js';
import '@lion/select-rich/lion-options.js'; import '@lion/listbox/lion-options.js';
import '@lion/select-rich/lion-option.js'; import '@lion/listbox/lion-option.js';
``` ```
> No need to npm install `@lion/option` separately, it comes with the rich select as a dependency > No need to npm install `@lion/option` separately, it comes with the rich select as a dependency
@ -385,7 +385,7 @@ class MyInvokerButton extends LitElement() {
_contentTemplate() { _contentTemplate() {
if (this.selectedElement) { if (this.selectedElement) {
const labelNodes = Array.from(this.selectedElement.querySelectorAll('*')); const labelNodes = Array.from(this.selectedElement.childNodes);
// Nested html in the selected option // Nested html in the selected option
if (labelNodes.length > 0) { if (labelNodes.length > 0) {
// Cloning is important if you plan on passing nodes straight to a lit template // Cloning is important if you plan on passing nodes straight to a lit template

View file

@ -1,4 +1,12 @@
/**
* @deprecated
* Import here for backwards compatibility
*/
export { LionOptions } from '@lion/listbox';
/**
* @deprecated
* Import here for backwards compatibility
*/
export { LionOption } from '@lion/listbox';
export { LionSelectRich } from './src/LionSelectRich.js'; export { LionSelectRich } from './src/LionSelectRich.js';
export { LionSelectInvoker } from './src/LionSelectInvoker.js'; export { LionSelectInvoker } from './src/LionSelectInvoker.js';
export { LionOptions } from './src/LionOptions.js';
export { LionOption } from './src/LionOption.js';

View file

@ -1,3 +1,5 @@
import { LionOption } from './src/LionOption.js'; /**
* @deprecated
customElements.define('lion-option', LionOption); * Import here for backwards compatibility
*/
import '@lion/listbox/lion-option.js';

View file

@ -1,3 +1,5 @@
import { LionOptions } from './src/LionOptions.js'; /**
* @deprecated
customElements.define('lion-options', LionOptions); * Import here for backwards compatibility
*/
import '@lion/listbox/lion-options.js';

View file

@ -37,6 +37,7 @@
"@lion/button": "0.7.12", "@lion/button": "0.7.12",
"@lion/core": "0.11.0", "@lion/core": "0.11.0",
"@lion/form-core": "0.6.0", "@lion/form-core": "0.6.0",
"@lion/listbox": "0.0.0",
"@lion/overlays": "0.18.0" "@lion/overlays": "0.18.0"
}, },
"keywords": [ "keywords": [

View file

@ -1,12 +1,14 @@
import { LionButton } from '@lion/button'; import { LionButton } from '@lion/button/src/LionButton.js';
import { css, html } from '@lion/core'; import { css, html } from '@lion/core';
/** /**
* LionSelectInvoker: invoker button consuming a selected element * @typedef {import('@lion/core').CSSResult} CSSResult
*
* @customElement lion-select-invoker
* @extends {LionButton}
*/ */
/**
* LionSelectInvoker: invoker button consuming a selected element
*/
// @ts-expect-error static get sryles return type
export class LionSelectInvoker extends LionButton { export class LionSelectInvoker extends LionButton {
static get styles() { static get styles() {
return [ return [
@ -21,25 +23,15 @@ export class LionSelectInvoker extends LionButton {
static get properties() { static get properties() {
return { return {
/** ...super.properties,
* @desc the option Element that is currently selected
*/
selectedElement: { selectedElement: {
type: Object, type: Object,
}, },
/**
* @desc When the connected LionSelectRich instance is readOnly,
* this should be reflected in the invoker as well
*/
readOnly: { readOnly: {
type: Boolean, type: Boolean,
reflect: true, reflect: true,
attribute: 'readonly', attribute: 'readonly',
}, },
/**
* @desc When the connected LionSelectRich instance has only one option,
* this should be reflected in the invoker as well
*/
singleOption: { singleOption: {
type: Boolean, type: Boolean,
reflect: true, reflect: true,
@ -54,43 +46,56 @@ export class LionSelectInvoker extends LionButton {
after: () => { after: () => {
const icon = document.createElement('span'); const icon = document.createElement('span');
icon.textContent = '▼'; icon.textContent = '▼';
icon.setAttribute('role', 'img');
icon.setAttribute('aria-hidden', 'true');
return icon; return icon;
}, },
}; };
} }
get _contentWrapperNode() { get _contentWrapperNode() {
return this.shadowRoot.getElementById('content-wrapper'); return /** @type {ShadowRoot} */ (this.shadowRoot).getElementById('content-wrapper');
} }
constructor() { constructor() {
super(); super();
/**
* When the connected LionSelectRich instance is readOnly,
* this should be reflected in the invoker as well
*/
this.readOnly = false;
/**
* The option Element that is currently selected
* @type {HTMLElement | null}
*/
this.selectedElement = null; this.selectedElement = null;
/**
* When the connected LionSelectRich instance has only one option,
* this should be reflected in the invoker as well
*/
this.singleOption = false;
this.type = 'button'; this.type = 'button';
} }
connectedCallback() { // eslint-disable-next-line class-methods-use-this
if (super.connectedCallback) { __handleKeydown(/** @type {KeyboardEvent} */ event) {
super.connectedCallback(); switch (event.key) {
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
/* no default */
} }
}
const handleKeydown = event => { connectedCallback() {
switch (event.key) { super.connectedCallback();
case 'ArrowDown': this.addEventListener('keydown', this.__handleKeydown);
case 'ArrowUp':
event.preventDefault();
/* no default */
}
};
this.handleKeydown = handleKeydown;
this.addEventListener('keydown', this.handleKeydown);
} }
disconnectedCallback() { disconnectedCallback() {
if (super.disconnectedCallback) { super.disconnectedCallback();
super.disconnectedCallback(); this.removeEventListener('keydown', this.__handleKeydown);
}
this.removeEventListener('keydown', this.handleKeydown);
} }
_contentTemplate() { _contentTemplate() {

View file

@ -1,19 +1,16 @@
import { import { LionListbox } from '@lion/listbox';
ChoiceGroupMixin, import { html, ScopedElementsMixin, SlotMixin } from '@lion/core';
FormControlMixin,
FormRegistrarMixin,
InteractionStateMixin,
ValidateMixin,
} from '@lion/form-core';
import { css, html, LitElement, ScopedElementsMixin, SlotMixin } from '@lion/core';
import { OverlayMixin, withDropdownConfig } from '@lion/overlays'; import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
import './differentKeyNamesShimIE.js'; import '@lion/core/src/differentKeyEventNamesShimIE.js';
import { LionSelectInvoker } from './LionSelectInvoker.js'; import { LionSelectInvoker } from './LionSelectInvoker.js';
function uuid() { /**
return Math.random().toString(36).substr(2, 10); * @typedef {import('@lion/listbox').LionOptions} LionOptions
} */
/**
* @typedef {import('@open-wc/scoped-elements/src/types').ScopedElementsHost} ScopedElementsHost
*/
function detectInteractionMode() { function detectInteractionMode() {
if (navigator.appVersion.indexOf('Mac') !== -1) { if (navigator.appVersion.indexOf('Mac') !== -1) {
@ -22,41 +19,11 @@ function detectInteractionMode() {
return 'windows/linux'; return 'windows/linux';
} }
function isInView(container, element, partial = false) {
const cTop = container.scrollTop;
const cBottom = cTop + container.clientHeight;
const eTop = element.offsetTop;
const eBottom = eTop + element.clientHeight;
const isTotal = eTop >= cTop && eBottom <= cBottom;
let isPartial;
if (partial === true) {
isPartial = (eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom);
} else if (typeof partial === 'number') {
if (eTop < cTop && eBottom > cTop) {
isPartial = ((eBottom - cTop) * 100) / element.clientHeight > partial;
} else if (eBottom > cBottom && eTop < cBottom) {
isPartial = ((cBottom - eTop) * 100) / element.clientHeight > partial;
}
}
return isTotal || isPartial;
}
/** /**
* LionSelectRich: wraps the <lion-listbox> element * LionSelectRich: wraps the <lion-listbox> element
*
* @customElement lion-select-rich
* @extends {LitElement}
*/ */
export class LionSelectRich extends ScopedElementsMixin( // @ts-expect-error base constructors same return type
ChoiceGroupMixin( export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) {
OverlayMixin(
FormRegistrarMixin(
InteractionStateMixin(ValidateMixin(FormControlMixin(SlotMixin(LitElement)))),
),
),
),
) {
static get scopedElements() { static get scopedElements() {
return { return {
...super.scopedElements, ...super.scopedElements,
@ -66,174 +33,109 @@ export class LionSelectRich extends ScopedElementsMixin(
static get properties() { static get properties() {
return { return {
disabled: { navigateWithinInvoker: {
type: Boolean, type: Boolean,
reflect: true, attribute: 'navigate-within-invoker',
}, },
readOnly: {
type: Boolean,
reflect: true,
attribute: 'readonly',
},
interactionMode: { interactionMode: {
type: String, type: String,
attribute: 'interaction-mode', attribute: 'interaction-mode',
}, },
singleOption: {
/**
* When setting this to true, on initial render, no option will be selected.
* It it advisable to override `_noSelectionTemplate` method in the select-invoker
* to render some kind of placeholder initially
*/
hasNoDefaultSelected: {
type: Boolean, type: Boolean,
reflect: true, reflect: true,
attribute: 'has-no-default-selected', attribute: 'single-option',
}, },
}; };
} }
static get styles() {
return [
css`
:host {
display: block;
}
:host([hidden]) {
display: none;
}
:host([disabled]) {
color: #adadad;
}
`,
];
}
get slots() { get slots() {
return { return {
...super.slots, ...super.slots,
invoker: () => invoker: () => document.createElement(LionSelectRich.getScopedTagName('lion-select-invoker')),
document.createElement(this.constructor.getScopedTagName('lion-select-invoker')),
}; };
} }
/** @type {LionSelectInvoker} */
get _invokerNode() { get _invokerNode() {
return Array.from(this.children).find(child => child.slot === 'invoker'); return /** @type {LionSelectInvoker} */ (Array.from(this.children).find(
} child => child.slot === 'invoker',
));
get _listboxNode() {
return (
(this._overlayCtrl && this._overlayCtrl.contentNode) ||
Array.from(this.children).find(child => child.slot === 'input')
);
}
get _listboxActiveDescendantNode() {
return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`);
}
get serializedValue() {
return this.modelValue;
}
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
// If you override one, gotta override the other, they go in pairs.
set serializedValue(value) {
super.serializedValue = value;
}
get checkedIndex() {
let checkedIndex = -1;
this.formElements.forEach((option, i) => {
if (option.checked) {
checkedIndex = i;
}
});
return checkedIndex;
}
set checkedIndex(index) {
if (this._listboxNode.children[index]) {
this._listboxNode.children[index].checked = true;
}
}
get activeIndex() {
return this.formElements.findIndex(el => el.active === true);
} }
get _scrollTargetNode() { get _scrollTargetNode() {
// @ts-expect-error _scrollTargetNode not on type
return this._overlayContentNode._scrollTargetNode || this._overlayContentNode; return this._overlayContentNode._scrollTargetNode || this._overlayContentNode;
} }
set activeIndex(index) { get checkedIndex() {
if (this.formElements[index]) { return /** @type {number} */ (super.checkedIndex);
const el = this.formElements[index]; }
el.active = true;
if (!isInView(this._scrollTargetNode, el)) { set checkedIndex(i) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); super.checkedIndex = i;
}
}
} }
constructor() { constructor() {
super(); super();
this.interactionMode = 'auto';
this.disabled = false;
// for interaction states
this._listboxActiveDescendant = null;
this.__hasInitialSelectedFormElement = false;
this.hasNoDefaultSelected = false;
this._repropagationRole = 'choice-group'; // configures FormControlMixin
/**
* When invoker has focus, up and down arrow keys changes active state of listbox,
* without opening overlay.
* @type {Boolean}
*/
this.navigateWithinInvoker = false;
/**
* Aligns behavior for 'selectionFollowFocus' and 'navigateWithinInvoker' with
* platform. When 'auto' (default), platform is automatically detected
* @type {'windows/linux'|'mac'|'auto'}
*/
this.interactionMode = 'auto';
this.singleOption = false;
this.__onKeyUp = this.__onKeyUp.bind(this);
this.__invokerOnBlur = this.__invokerOnBlur.bind(this);
this.__overlayOnHide = this.__overlayOnHide.bind(this);
this.__overlayOnShow = this.__overlayOnShow.bind(this);
this.__invokerOnClick = this.__invokerOnClick.bind(this);
this.__overlayBeforeShow = this.__overlayBeforeShow.bind(this);
this.__focusInvokerOnLabelClick = this.__focusInvokerOnLabelClick.bind(this); this.__focusInvokerOnLabelClick = this.__focusInvokerOnLabelClick.bind(this);
} }
connectedCallback() { connectedCallback() {
// need to do this before anything else super.connectedCallback();
this._listboxNode.registrationTarget = this;
if (super.connectedCallback) {
super.connectedCallback();
}
this._invokerNode.selectedElement = this.formElements[this.checkedIndex]; this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
this.__setupInvokerNode(); this.__setupInvokerNode();
this.__setupListboxNode();
this.__setupEventListeners();
this.__toggleInvokerDisabled(); this.__toggleInvokerDisabled();
if (this._labelNode) { if (this._labelNode) {
this._labelNode.addEventListener('click', this.__focusInvokerOnLabelClick); this._labelNode.addEventListener('click', this.__focusInvokerOnLabelClick);
} }
this.registrationComplete.then(() => { this.addEventListener('keyup', this.__onKeyUp);
this.__initInteractionStates();
});
} }
disconnectedCallback() { disconnectedCallback() {
if (super.disconnectedCallback) { super.disconnectedCallback();
super.disconnectedCallback();
}
if (this._labelNode) { if (this._labelNode) {
this._labelNode.removeEventListener('click', this.__toggleChecked); this._labelNode.removeEventListener('click', this.__focusInvokerOnLabelClick);
} }
this._scrollTargetNode.removeEventListener('keydown', this.__overlayOnHide);
this.__teardownInvokerNode(); this.__teardownInvokerNode();
this.__teardownListboxNode(); this.removeEventListener('keyup', this.__onKeyUp);
this.__teardownEventListeners();
} }
/**
* @param {string} name
* @param {unknown} oldValue
*/
requestUpdateInternal(name, oldValue) { requestUpdateInternal(name, oldValue) {
super.requestUpdateInternal(name, oldValue); super.requestUpdateInternal(name, oldValue);
if (name === 'interactionMode') { if (name === 'interactionMode') {
if (this.interactionMode === 'auto') { if (this.interactionMode === 'auto') {
this.interactionMode = detectInteractionMode(); this.interactionMode = detectInteractionMode();
} else {
this.selectionFollowsFocus = Boolean(this.interactionMode === 'windows/linux');
this.navigateWithinInvoker = Boolean(this.interactionMode === 'windows/linux');
} }
} }
@ -256,35 +158,21 @@ export class LionSelectRich extends ScopedElementsMixin(
this.initInteractionState(); this.initInteractionState();
} }
get _inputNode() { /**
// In FormControl, we get direct child [slot="input"]. This doesn't work, because the overlay * @param {import('lit-element').PropertyValues } changedProperties
// system wraps it in [slot="_overlay-shadow-outlet"] */
return this.querySelector('[slot="input"]');
}
render() {
return html`
${this._labelTemplate()} ${this._helpTextTemplate()} ${this._inputGroupTemplate()}
${this._feedbackTemplate()}
<slot name="_overlay-shadow-outlet"></slot>
`;
}
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (this.formElements.length === 1) { if (this.formElements.length === 1) {
this.singleOption = true;
this._invokerNode.singleOption = true; this._invokerNode.singleOption = true;
} }
if (changedProperties.has('disabled')) { if (changedProperties.has('disabled')) {
if (this.disabled) { if (this.disabled) {
this._invokerNode.makeRequestToBeDisabled(); this._invokerNode.makeRequestToBeDisabled();
this.__requestOptionsToBeDisabled();
} else { } else {
this._invokerNode.retractRequestToBeDisabled(); this._invokerNode.retractRequestToBeDisabled();
this.__retractRequestOptionsToBeDisabled();
} }
} }
@ -299,13 +187,13 @@ export class LionSelectRich extends ScopedElementsMixin(
if (changedProperties.has('_ariaDescribedNodes')) { if (changedProperties.has('_ariaDescribedNodes')) {
this._invokerNode.setAttribute( this._invokerNode.setAttribute(
'aria-describedby', 'aria-describedby',
this._inputNode.getAttribute('aria-describedby'), /** @type {string} */ (this._inputNode.getAttribute('aria-describedby')),
); );
} }
if (changedProperties.has('showsFeedbackFor')) { if (changedProperties.has('showsFeedbackFor')) {
// The ValidateMixin sets aria-invalid on the inputNode, but in this component we also need it on the invoker // The ValidateMixin sets aria-invalid on the inputNode, but in this component we also need it on the invoker
this._invokerNode.setAttribute('aria-invalid', this._hasFeedbackVisibleFor('error')); this._invokerNode.setAttribute('aria-invalid', `${this._hasFeedbackVisibleFor('error')}`);
} }
} }
@ -314,6 +202,7 @@ export class LionSelectRich extends ScopedElementsMixin(
} }
} }
/** @deprecated. use _overlayCtrl.toggle */
toggle() { toggle() {
this.opened = !this.opened; this.opened = !this.opened;
} }
@ -321,7 +210,7 @@ export class LionSelectRich extends ScopedElementsMixin(
/** /**
* @override * @override
*/ */
// eslint-disable-next-line // eslint-disable-next-line class-methods-use-this
_inputGroupInputTemplate() { _inputGroupInputTemplate() {
return html` return html`
<div class="input-group__input"> <div class="input-group__input">
@ -334,66 +223,6 @@ export class LionSelectRich extends ScopedElementsMixin(
`; `;
} }
/**
* Overrides FormRegistrar adding to make sure children have specific default states when added
*
* @override
* @param {*} child
* @param {Number} indexToInsertAt
*/
addFormElement(child, indexToInsertAt) {
super.addFormElement(child, indexToInsertAt);
// we need to adjust the elements being registered
/* eslint-disable no-param-reassign */
child.id = child.id || `${this.localName}-option-${uuid()}`;
if (this.disabled) {
child.makeRequestToBeDisabled();
}
// the first elements checked by default
if (
!this.hasNoDefaultSelected &&
!this.__hasInitialSelectedFormElement &&
(!child.disabled || this.disabled)
) {
child.active = true;
child.checked = true;
this.__hasInitialSelectedFormElement = true;
}
// TODO: small perf improvement could be made if logic below would be scheduled to next update,
// so it occurs once for all options
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
this.formElements.forEach((el, idx) => {
el.setAttribute('aria-posinset', idx + 1);
});
this.__proxyChildModelValueChanged({ target: child });
this.resetInteractionState();
/* eslint-enable no-param-reassign */
}
__setupEventListeners() {
this.__onChildActiveChanged = this.__onChildActiveChanged.bind(this);
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
this.__onKeyUp = this.__onKeyUp.bind(this);
this._listboxNode.addEventListener('active-changed', this.__onChildActiveChanged);
this._listboxNode.addEventListener('model-value-changed', this.__proxyChildModelValueChanged);
this.addEventListener('keyup', this.__onKeyUp);
}
__teardownEventListeners() {
this._listboxNode.removeEventListener('active-changed', this.__onChildActiveChanged);
this._listboxNode.removeEventListener(
'model-value-changed',
this.__proxyChildModelValueChanged,
);
this._listboxNode.removeEventListener('keyup', this.__onKeyUp);
}
__toggleInvokerDisabled() { __toggleInvokerDisabled() {
if (this._invokerNode) { if (this._invokerNode) {
this._invokerNode.disabled = this.disabled; this._invokerNode.disabled = this.disabled;
@ -401,34 +230,6 @@ export class LionSelectRich extends ScopedElementsMixin(
} }
} }
__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);
});
}
__proxyChildModelValueChanged(ev) {
// We need to redispatch the model-value-changed event on 'this', so it will
// align with FormControl.__repropagateChildrenValues method. Also, this makes
// it act like a portal, in case the listbox is put in a modal overlay on body level.
if (ev.stopPropagation) {
ev.stopPropagation();
}
this.dispatchEvent(new CustomEvent('model-value-changed', { detail: { element: ev.target } }));
}
__syncInvokerElement() { __syncInvokerElement() {
// sync to invoker // sync to invoker
if (this._invokerNode) { if (this._invokerNode) {
@ -436,141 +237,6 @@ export class LionSelectRich extends ScopedElementsMixin(
} }
} }
__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() { __setupInvokerNode() {
this._invokerNode.id = `invoker-${this._inputId}`; this._invokerNode.id = `invoker-${this._inputId}`;
this._invokerNode.setAttribute('aria-haspopup', 'listbox'); this._invokerNode.setAttribute('aria-haspopup', 'listbox');
@ -578,17 +244,19 @@ export class LionSelectRich extends ScopedElementsMixin(
this.__setupInvokerNodeEventListener(); this.__setupInvokerNodeEventListener();
} }
__invokerOnClick() {
if (!this.disabled && !this.readOnly && !this.singleOption && !this.__blockListShow) {
this._overlayCtrl.toggle();
}
}
__invokerOnBlur() {
this.dispatchEvent(new Event('blur'));
}
__setupInvokerNodeEventListener() { __setupInvokerNodeEventListener() {
this.__invokerOnClick = () => {
if (!this.disabled && !this.readOnly && !this.singleOption) {
this._overlayCtrl.toggle();
}
};
this._invokerNode.addEventListener('click', this.__invokerOnClick); this._invokerNode.addEventListener('click', this.__invokerOnClick);
this.__invokerOnBlur = () => {
this.dispatchEvent(new Event('blur'));
};
this._invokerNode.addEventListener('blur', this.__invokerOnBlur); this._invokerNode.addEventListener('blur', this.__invokerOnBlur);
} }
@ -598,46 +266,8 @@ export class LionSelectRich extends ScopedElementsMixin(
} }
/** /**
* For ShadyDom the listboxNode is available right from the start so we can add those events * @override OverlayMixin
* 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);
}
}
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() { _defineOverlayConfig() {
return { return {
@ -653,36 +283,39 @@ export class LionSelectRich extends ScopedElementsMixin(
*/ */
_noDefaultSelectedInheritsWidth() { _noDefaultSelectedInheritsWidth() {
if (this.checkedIndex === -1) { if (this.checkedIndex === -1) {
this._overlayCtrl.inheritsReferenceWidth = 'min'; this._overlayCtrl.updateConfig({ inheritsReferenceWidth: 'min' });
} else { } else {
this._overlayCtrl.inheritsReferenceWidth = this._initialInheritsReferenceWidth; this._overlayCtrl.updateConfig({
inheritsReferenceWidth: this._initialInheritsReferenceWidth,
});
} }
} }
__overlayBeforeShow() {
if (this.hasNoDefaultSelected) {
this._noDefaultSelectedInheritsWidth();
}
}
__overlayOnShow() {
if (this.checkedIndex != null) {
this.activeIndex = this.checkedIndex;
}
this._listboxNode.focus();
}
__overlayOnHide() {
this._invokerNode.focus();
}
_setupOverlayCtrl() { _setupOverlayCtrl() {
super._setupOverlayCtrl(); super._setupOverlayCtrl();
this._initialInheritsReferenceWidth = this._overlayCtrl.inheritsReferenceWidth; this._initialInheritsReferenceWidth = this._overlayCtrl.inheritsReferenceWidth;
this.__overlayBeforeShow = () => {
if (this.hasNoDefaultSelected) {
this._noDefaultSelectedInheritsWidth();
}
};
this.__overlayOnShow = () => {
if (this.checkedIndex != null) {
this.activeIndex = this.checkedIndex;
}
this._listboxNode.focus();
};
this._overlayCtrl.addEventListener('before-show', this.__overlayBeforeShow); this._overlayCtrl.addEventListener('before-show', this.__overlayBeforeShow);
this._overlayCtrl.addEventListener('show', this.__overlayOnShow); this._overlayCtrl.addEventListener('show', this.__overlayOnShow);
this.__overlayOnHide = () => {
this._invokerNode.focus();
};
this._overlayCtrl.addEventListener('hide', this.__overlayOnHide); this._overlayCtrl.addEventListener('hide', this.__overlayOnHide);
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
} }
_teardownOverlayCtrl() { _teardownOverlayCtrl() {
@ -692,21 +325,6 @@ export class LionSelectRich extends ScopedElementsMixin(
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide); this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
} }
__preventScrollingWithArrowKeys(ev) {
if (this.disabled) {
return;
}
const { key } = ev;
switch (key) {
case 'ArrowUp':
case 'ArrowDown':
case 'Home':
case 'End':
ev.preventDefault();
/* no default */
}
}
__focusInvokerOnLabelClick() { __focusInvokerOnLabelClick() {
this._invokerNode.focus(); this._invokerNode.focus();
} }
@ -725,14 +343,103 @@ export class LionSelectRich extends ScopedElementsMixin(
return this._listboxNode; return this._listboxNode;
} }
set fieldName(value) { /**
this.__fieldName = value; * @param {KeyboardEvent} ev
*/
__onKeyUp(ev) {
if (this.disabled) {
return;
}
if (this.opened) {
return;
}
const { key } = ev;
switch (key) {
case 'ArrowUp':
ev.preventDefault();
if (this.navigateWithinInvoker) {
this.setCheckedIndex(this._getPreviousEnabledOption(this.checkedIndex));
} else {
this.opened = true;
}
break;
case 'ArrowDown':
ev.preventDefault();
if (this.navigateWithinInvoker) {
this.setCheckedIndex(this._getNextEnabledOption(this.checkedIndex));
} else {
this.opened = true;
}
break;
/* no default */
}
} }
get fieldName() { /**
const label = * @desc
this.label || * Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
(this.querySelector('[slot=label]') && this.querySelector('[slot=label]').textContent); * an item.
return this.__fieldName || label || this.name; *
* @param {KeyboardEvent} ev - the keydown event object
*/
_listboxOnKeyDown(ev) {
super._listboxOnKeyDown(ev);
if (this.disabled) {
return;
}
const { key } = ev;
switch (key) {
case 'Tab':
// Tab can only be caught in keydown
this.opened = false;
break;
/* no default */
case 'Escape':
this.opened = false;
this.__blockListShowDuringTransition();
break;
case 'Enter':
case ' ':
this.opened = false;
this.__blockListShowDuringTransition();
break;
/* no default */
}
}
_listboxOnClick = () => {
this.opened = false;
};
_setupListboxNodeInteractions() {
super._setupListboxNodeInteractions();
this._listboxNode.addEventListener('click', this._listboxOnClick);
}
_teardownListboxNode() {
super._teardownListboxNode();
if (this._listboxNode) {
this._listboxNode.removeEventListener('click', this._listboxOnClick);
}
}
/**
* Normally, when textbox gets focus or a char is typed, it opens listbox.
* In transition phases (like clicking option) we prevent this.
*/
__blockListShowDuringTransition() {
this.__blockListShow = true;
// We need this timeout to make sure click handler triggered by keyup (space/enter) of
// button has been executed.
// TODO: alternative would be to let the 'checking' party 'release' this boolean
// Or: call 'stopPropagation' on keyup of keys that have been handled in keydown
setTimeout(() => {
this.__blockListShow = false;
}, 200);
} }
} }

View file

@ -1,33 +0,0 @@
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

@ -1,49 +0,0 @@
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,6 @@
import { runListboxMixinSuite } from '@lion/listbox/test-suites/ListboxMixin.suite.js';
import '../lion-select-rich.js';
describe('<lion-select-rich> integration with ListboxMixin', () => {
runListboxMixinSuite({ tagString: 'lion-select-rich' });
});

View file

@ -1,9 +1,9 @@
import { OverlayMixin } from '@lion/overlays'; import { OverlayMixin } from '@lion/overlays';
import { LitElement } from 'lit-element'; import { LitElement } from 'lit-element';
import { defineCE, fixture, html, expect, unsafeStatic } from '@open-wc/testing'; import { defineCE, fixture, html, expect, unsafeStatic } from '@open-wc/testing';
import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '../lion-select-rich.js'; import '../lion-select-rich.js';
import '../lion-options.js';
import '../lion-option.js';
const tagString = defineCE( const tagString = defineCE(
class extends OverlayMixin(LitElement) { class extends OverlayMixin(LitElement) {

View file

@ -1,105 +1,13 @@
import { Required } from '@lion/form-core'; import { Required } from '@lion/form-core';
import { expect, html, triggerBlurFor, triggerFocusFor, fixture } from '@open-wc/testing'; import { expect, html, triggerBlurFor, triggerFocusFor, fixture } from '@open-wc/testing';
import '../lion-option.js'; import '@lion/core/src/differentKeyEventNamesShimIE.js';
import '../lion-options.js'; import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '../lion-select-rich.js'; import '../lion-select-rich.js';
import './keyboardEventShimIE.js';
describe('lion-select-rich interactions', () => { 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.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.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.equal(20);
});
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', () => { 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 () => { it('navigates to first and last option with [Home] and [End] keys', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich opened interaction-mode="windows/linux"> <lion-select-rich opened interaction-mode="windows/linux">
@ -113,44 +21,12 @@ describe('lion-select-rich interactions', () => {
`); `);
expect(el.modelValue).to.equal(30); expect(el.modelValue).to.equal(30);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
expect(el.modelValue).to.equal(10); expect(el.modelValue).to.equal(10);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'End' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
expect(el.modelValue).to.equal(40); expect(el.modelValue).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', () => { describe('Keyboard navigation Windows', () => {
@ -180,12 +56,12 @@ describe('lion-select-rich interactions', () => {
expect(el.checkedIndex).to.equal(0); expect(el.checkedIndex).to.equal(0);
expectOnlyGivenOneOptionToBeChecked(options, 0); expectOnlyGivenOneOptionToBeChecked(options, 0);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.activeIndex).to.equal(1); expect(el.activeIndex).to.equal(1);
expect(el.checkedIndex).to.equal(1); expect(el.checkedIndex).to.equal(1);
expectOnlyGivenOneOptionToBeChecked(options, 1); expectOnlyGivenOneOptionToBeChecked(options, 1);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(el.activeIndex).to.equal(0); expect(el.activeIndex).to.equal(0);
expect(el.checkedIndex).to.equal(0); expect(el.checkedIndex).to.equal(0);
expectOnlyGivenOneOptionToBeChecked(options, 0); expectOnlyGivenOneOptionToBeChecked(options, 0);
@ -226,30 +102,6 @@ describe('lion-select-rich interactions', () => {
}); });
}); });
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', () => { describe('Disabled', () => {
it('cannot be focused if disabled', async () => { it('cannot be focused if disabled', async () => {
const el = await fixture(html` const el = await fixture(html`
@ -260,32 +112,6 @@ describe('lion-select-rich interactions', () => {
expect(el._invokerNode.tabIndex).to.equal(-1); 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.modelValue).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.modelValue).to.equal(10);
});
it('cannot be opened via click if disabled', async () => { it('cannot be opened via click if disabled', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich disabled> <lion-select-rich disabled>
@ -307,194 +133,9 @@ describe('lion-select-rich interactions', () => {
await el.updateComplete; await el.updateComplete;
expect(el._invokerNode.hasAttribute('disabled')).to.be.false; 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);
});
// flaky test
it.skip('skips disabled options while navigates to first and last option with [Home] and [End] keys', async () => {
const el = await fixture(html`
<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: 'End' }));
expect(el.activeIndex).to.equal(2);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' }));
expect(el.activeIndex).to.equal(1);
});
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;
});
});
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.equal(20);
});
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', () => { 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.modelValue = 20;
expect(el.dirty).to.be.true;
});
it('becomes touched if blurred once', async () => { it('becomes touched if blurred once', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich> <lion-select-rich>
@ -509,114 +150,9 @@ describe('lion-select-rich interactions', () => {
await triggerBlurFor(el._invokerNode); await triggerBlurFor(el._invokerNode);
expect(el.touched).to.be.true; 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 .validators=${[new 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.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).to.have.a.property('Required');
el.modelValue = 20;
expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).not.to.have.a.property('Required');
});
}); });
describe('Accessibility', () => { 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}`);
});
});
it('sets [aria-invalid="true"] to "._invokerNode" when there is an error', async () => { it('sets [aria-invalid="true"] to "._invokerNode" when there is an error', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich .validators=${[new Required()]}> <lion-select-rich .validators=${[new Required()]}>

View file

@ -1,6 +1,5 @@
import { LitElement } from '@lion/core'; import { LitElement } from '@lion/core';
import { OverlayController } from '@lion/overlays'; import { OverlayController } from '@lion/overlays';
import { Required } from '@lion/form-core';
import { import {
aTimeout, aTimeout,
defineCE, defineCE,
@ -11,111 +10,13 @@ import {
fixture, fixture,
} from '@open-wc/testing'; } from '@open-wc/testing';
import { LionSelectInvoker, LionSelectRich } from '../index.js'; import { LionSelectInvoker, LionSelectRich } from '../index.js';
import '../lion-option.js';
import '../lion-options.js'; import '@lion/core/src/differentKeyEventNamesShimIE.js';
import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '../lion-select-rich.js'; import '../lion-select-rich.js';
import './keyboardEventShimIE.js';
describe('lion-select-rich', () => { describe('lion-select-rich', () => {
it('has a single modelValue representing the currently checked option', async () => {
const el = await fixture(html`
<lion-select-rich name="foo">
<lion-options slot="input">
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
expect(el.modelValue).to.equal(10);
});
it('automatically sets the name attribute of child checkboxes to its own name', async () => {
const el = await fixture(html`
<lion-select-rich name="foo">
<lion-options slot="input">
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
expect(el.formElements[0].name).to.equal('foo');
expect(el.formElements[1].name).to.equal('foo');
const validChild = await fixture(html` <lion-option .choiceValue=${30}>Item 3</lion-option> `);
el.appendChild(validChild);
expect(el.formElements[2].name).to.equal('foo');
});
it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
const el = await fixture(html`
<lion-select-rich name="foo">
<lion-options slot="input">
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
const invalidChild = await fixture(html` <lion-option .modelValue=${'Lara'}></lion-option> `);
expect(() => {
el.addFormElement(invalidChild);
}).to.throw(
'The lion-select-rich name="foo" does not allow to register lion-option with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }',
);
});
it('throws if a child element with a different name than the group tries to register', async () => {
const el = await fixture(html`
<lion-select-rich name="gender">
<lion-options slot="input">
<lion-option .choiceValue=${'female'} checked></lion-option>
<lion-option .choiceValue=${'other'}></lion-option>
</lion-options>
</lion-select-rich>
`);
const invalidChild = await fixture(html`
<lion-option name="foo" .choiceValue=${'male'}></lion-option>
`);
expect(() => {
el.addFormElement(invalidChild);
}).to.throw(
'The lion-select-rich name="gender" does not allow to register lion-option with custom names (name="foo" given)',
);
});
it('can set initial modelValue on creation', async () => {
const el = await fixture(html`
<lion-select-rich name="gender" .modelValue=${'other'}>
<lion-options slot="input">
<lion-option .choiceValue=${'male'}></lion-option>
<lion-option .choiceValue=${'female'}></lion-option>
<lion-option .choiceValue=${'other'}></lion-option>
</lion-options>
</lion-select-rich>
`);
expect(el.modelValue).to.equal('other');
expect(el.formElements[2].checked).to.be.true;
});
it(`has a fieldName based on the label`, async () => {
const el1 = await fixture(html`
<lion-select-rich label="foo"><lion-options slot="input"></lion-options></lion-select-rich>
`);
expect(el1.fieldName).to.equal(el1._labelNode.textContent);
const el2 = await fixture(html`
<lion-select-rich>
<label slot="label">bar</label><lion-options slot="input"></lion-options>
</lion-select-rich>
`);
expect(el2.fieldName).to.equal(el2._labelNode.textContent);
});
it('clicking the label should focus the invoker', async () => { it('clicking the label should focus the invoker', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich label="foo"> <lion-select-rich label="foo">
@ -127,119 +28,6 @@ describe('lion-select-rich', () => {
expect(document.activeElement === el._invokerNode).to.be.true; expect(document.activeElement === el._invokerNode).to.be.true;
}); });
it(`has a fieldName based on the name if no label exists`, async () => {
const el = await fixture(html`
<lion-select-rich name="foo"><lion-options slot="input"></lion-options></lion-select-rich>
`);
expect(el.fieldName).to.equal(el.name);
});
it(`can override fieldName`, async () => {
const el = await fixture(html`
<lion-select-rich label="foo" .fieldName="${'bar'}"
><lion-options slot="input"></lion-options
></lion-select-rich>
`);
expect(el.__fieldName).to.equal(el.fieldName);
});
it('does not have a tabindex', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
expect(el.hasAttribute('tabindex')).to.be.false;
});
it('delegates the name attribute to its children options', async () => {
const el = await fixture(html`
<lion-select-rich name="foo">
<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 optOne = el.querySelectorAll('lion-option')[0];
const optTwo = el.querySelectorAll('lion-option')[1];
expect(optOne.name).to.equal('foo');
expect(optTwo.name).to.equal('foo');
});
it('supports validation', async () => {
const el = await fixture(html`
<lion-select-rich
id="color"
name="color"
label="Favorite color"
.validators="${[new Required()]}"
>
<lion-options slot="input">
<lion-option .choiceValue=${null}>select a color</lion-option>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
</lion-select-rich>
`);
expect(el.hasFeedbackFor.includes('error')).to.be.true;
expect(el.showsFeedbackFor.includes('error')).to.be.false;
// test submitted prop explicitly, since we dont extend field, we add the prop manually
el.submitted = true;
await el.updateComplete;
expect(el.showsFeedbackFor.includes('error')).to.be.true;
el._listboxNode.children[1].checked = true;
await el.updateComplete;
expect(el.hasFeedbackFor.includes('error')).to.be.false;
expect(el.showsFeedbackFor.includes('error')).to.be.false;
el._listboxNode.children[0].checked = true;
await el.updateComplete;
expect(el.hasFeedbackFor.includes('error')).to.be.true;
expect(el.showsFeedbackFor.includes('error')).to.be.true;
});
it('supports having no default selection initially', async () => {
const el = await fixture(html`
<lion-select-rich id="color" name="color" label="Favorite color" has-no-default-selected>
<lion-options slot="input">
<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>
`);
expect(el.selectedElement).to.be.undefined;
expect(el.modelValue).to.equal('');
});
it('supports changing the selection through serializedValue setter', async () => {
const el = await fixture(html`
<lion-select-rich id="color" name="color" label="Favorite color">
<lion-options slot="input">
<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>
`);
expect(el.checkedIndex).to.equal(0);
expect(el.serializedValue).to.equal('red');
el.serializedValue = 'hotpink';
expect(el.checkedIndex).to.equal(1);
expect(el.serializedValue).to.equal('hotpink');
});
describe('Invoker', () => { describe('Invoker', () => {
it('generates an lion-select-invoker if no invoker is provided', async () => { it('generates an lion-select-invoker if no invoker is provided', async () => {
const el = await fixture(html` const el = await fixture(html`
@ -454,7 +242,7 @@ describe('lion-select-rich', () => {
`); `);
// The default is min, so we override that behavior here // The default is min, so we override that behavior here
el._overlayCtrl.inheritsReferenceWidth = 'full'; el._overlayCtrl.updateConfig({ inheritsReferenceWidth: 'full' });
el._initialInheritsReferenceWidth = 'full'; el._initialInheritsReferenceWidth = 'full';
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full'); expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full');
@ -486,7 +274,7 @@ describe('lion-select-rich', () => {
elSingleoption._invokerNode.click(); elSingleoption._invokerNode.click();
await elSingleoption.updateComplete; await elSingleoption.updateComplete;
expect(elSingleoption.singleOption).to.be.undefined; expect(elSingleoption.singleOption).to.be.false;
const optionELm = elSingleoption.querySelectorAll('lion-option')[0]; const optionELm = elSingleoption.querySelectorAll('lion-option')[0];
optionELm.parentNode.removeChild(optionELm); optionELm.parentNode.removeChild(optionELm);
@ -538,7 +326,7 @@ describe('lion-select-rich', () => {
<lion-options slot="input"></lion-options> <lion-options slot="input"></lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
@ -588,7 +376,7 @@ describe('lion-select-rich', () => {
<lion-options slot="input"></lion-options> <lion-options slot="input"></lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
}); });
@ -608,7 +396,7 @@ describe('lion-select-rich', () => {
el.activeIndex = 1; el.activeIndex = 1;
expect(el.checkedIndex).to.equal(0); expect(el.checkedIndex).to.equal(0);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
expect(el.checkedIndex).to.equal(1); expect(el.checkedIndex).to.equal(1);
}); });
@ -667,71 +455,9 @@ describe('lion-select-rich', () => {
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('true'); expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('true');
}); });
it('is accessible when closed', 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>
`);
await expect(el).to.be.accessible();
});
it('is accessible when opened', 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>
`);
el.opened = true;
await el.updateComplete;
await el.updateComplete; // need 2 awaits as overlay.show is an async function
await expect(el).to.be.accessible();
});
}); });
describe('Use cases', () => { 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.modelValue).to.deep.equal({
type: 'mastercard',
label: 'Master Card',
amount: 12000,
active: true,
});
el.checkedIndex = 1;
expect(el.modelValue).to.deep.equal({
type: 'visacard',
label: 'Visa Card',
amount: 0,
active: false,
});
});
it('keeps showing the selected item after a new item has been added in the selectedIndex position', async () => { it('keeps showing the selected item after a new item has been added in the selectedIndex position', async () => {
const mySelectContainerTagString = defineCE( const mySelectContainerTagString = defineCE(
class extends LitElement { class extends LitElement {
@ -880,24 +606,4 @@ describe('lion-select-rich', () => {
expect(el.modelValue).to.equal(''); expect(el.modelValue).to.equal('');
}); });
}); });
describe('Instantiation methods', () => {
it('can be instantiated via "document.createElement"', async () => {
let properlyInstantiated = false;
try {
const el = document.createElement('lion-select-rich');
const optionsEl = document.createElement('lion-options');
optionsEl.slot = 'input';
const optionEl = document.createElement('lion-option');
optionsEl.appendChild(optionEl);
el.appendChild(optionsEl);
properlyInstantiated = true;
} catch (e) {
throw Error(e);
}
expect(properlyInstantiated).to.be.true;
});
});
}); });

View file

@ -22,7 +22,8 @@
"packages/form-core/**/*.js", "packages/form-core/**/*.js",
"packages/overlays/**/*.js", "packages/overlays/**/*.js",
"packages/tooltip/**/*.js", "packages/tooltip/**/*.js",
"packages/button/src/**/*.js" "packages/button/src/**/*.js",
"packages/listbox/src/*.js"
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",