feat(listbox): new package 'listbox'
This commit is contained in:
parent
e42071d8dc
commit
0ec72ac330
34 changed files with 2339 additions and 1429 deletions
6
.changeset/nasty-rules-explain.md
Normal file
6
.changeset/nasty-rules-explain.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@lion/listbox': minor
|
||||
'@lion/select-rich': patch
|
||||
---
|
||||
|
||||
listbox package
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
'@lion/button': patch
|
||||
'@lion/overlays': patch
|
||||
'@lion/overlays': minor
|
||||
'@lion/tooltip': patch
|
||||
---
|
||||
|
||||
|
|
|
|||
1
packages/listbox/CHANGELOG.md
Normal file
1
packages/listbox/CHANGELOG.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
250
packages/listbox/README.md
Normal file
250
packages/listbox/README.md
Normal 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>
|
||||
`;
|
||||
```
|
||||
65
packages/listbox/docs/listboxData.js
Normal file
65
packages/listbox/docs/listboxData.js
Normal 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',
|
||||
];
|
||||
4
packages/listbox/index.js
Normal file
4
packages/listbox/index.js
Normal 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';
|
||||
3
packages/listbox/lion-listbox.js
Normal file
3
packages/listbox/lion-listbox.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { LionListbox } from './src/LionListbox.js';
|
||||
|
||||
customElements.define('lion-listbox', LionListbox);
|
||||
3
packages/listbox/lion-option.js
Normal file
3
packages/listbox/lion-option.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { LionOption } from './src/LionOption.js';
|
||||
|
||||
customElements.define('lion-option', LionOption);
|
||||
3
packages/listbox/lion-options.js
Normal file
3
packages/listbox/lion-options.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { LionOptions } from './src/LionOptions.js';
|
||||
|
||||
customElements.define('lion-options', LionOptions);
|
||||
46
packages/listbox/package.json
Normal file
46
packages/listbox/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
14
packages/listbox/src/LionListbox.js
Normal file
14
packages/listbox/src/LionListbox.js
Normal 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))),
|
||||
) {}
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { ChoiceInputMixin, FormRegisteringMixin } from '@lion/form-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
|
||||
* 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,
|
||||
* enabling SubClassers to style based on those states
|
||||
*/
|
||||
// @ts-expect-error
|
||||
export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMixin(LitElement))) {
|
||||
static get properties() {
|
||||
return {
|
||||
|
|
@ -15,10 +21,6 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
|||
type: Boolean,
|
||||
reflect: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
reflect: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -29,14 +31,17 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
|||
display: block;
|
||||
background-color: white;
|
||||
padding: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host([active]),
|
||||
:host(:hover) {
|
||||
background-color: #eee;
|
||||
}
|
||||
:host([active]) {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +70,10 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
|||
this.__registerEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {unknown} oldValue
|
||||
*/
|
||||
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) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('checked')) {
|
||||
|
|
@ -98,15 +110,22 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
|||
}
|
||||
|
||||
__registerEventListeners() {
|
||||
this.__onClick = () => {
|
||||
if (!this.disabled) {
|
||||
this.checked = true;
|
||||
}
|
||||
};
|
||||
this.addEventListener('click', this.__onClick);
|
||||
}
|
||||
|
||||
__unRegisterEventListeners() {
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -3,9 +3,6 @@ import { FormRegistrarPortalMixin } from '@lion/form-core';
|
|||
|
||||
/**
|
||||
* LionOptions
|
||||
*
|
||||
* @customElement lion-options
|
||||
* @extends {LitElement}
|
||||
*/
|
||||
export class LionOptions extends FormRegistrarPortalMixin(LitElement) {
|
||||
static get properties() {
|
||||
697
packages/listbox/src/ListboxMixin.js
Normal file
697
packages/listbox/src/ListboxMixin.js
Normal 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);
|
||||
799
packages/listbox/test-suites/ListboxMixin.suite.js
Normal file
799
packages/listbox/test-suites/ListboxMixin.suite.js
Normal 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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
3
packages/listbox/test/lion-listbox.test.js
Normal file
3
packages/listbox/test/lion-listbox.test.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { runListboxMixinSuite } from '../test-suites/ListboxMixin.suite.js';
|
||||
|
||||
runListboxMixinSuite();
|
||||
|
|
@ -1,35 +1,43 @@
|
|||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { LionOption } from '../src/LionOption.js';
|
||||
import '../lion-option.js';
|
||||
|
||||
describe('lion-option', () => {
|
||||
describe('Values', () => {
|
||||
it('has a modelValue', async () => {
|
||||
const el = await fixture(html`<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 });
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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>
|
||||
`);
|
||||
`));
|
||||
expect(el.getAttribute('aria-selected')).to.equal('true');
|
||||
|
||||
el.checked = false;
|
||||
|
|
@ -41,9 +49,9 @@ describe('lion-option', () => {
|
|||
});
|
||||
|
||||
it('asynchronously adds the attributes "aria-disabled" and "disabled" when disabled', async () => {
|
||||
const el = await fixture(html`
|
||||
const el = /** @type {LionOption} */ (await fixture(html`
|
||||
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
|
||||
`);
|
||||
`));
|
||||
expect(el.getAttribute('aria-disabled')).to.equal('true');
|
||||
expect(el.hasAttribute('disabled')).to.be.true;
|
||||
|
||||
|
|
@ -59,7 +67,9 @@ describe('lion-option', () => {
|
|||
|
||||
describe('State reflection', () => {
|
||||
it('asynchronously adds the attribute "active" when active', async () => {
|
||||
const el = await fixture(html`<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.hasAttribute('active')).to.be.false;
|
||||
|
||||
|
|
@ -77,7 +87,9 @@ describe('lion-option', () => {
|
|||
});
|
||||
|
||||
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;
|
||||
el.click();
|
||||
await el.updateComplete;
|
||||
|
|
@ -86,9 +98,12 @@ describe('lion-option', () => {
|
|||
|
||||
it('fires active-changed event', async () => {
|
||||
const activeSpy = sinon.spy();
|
||||
const el = await fixture(html`
|
||||
<lion-option .choiceValue=${10} @active-changed="${activeSpy}"></lion-option>
|
||||
`);
|
||||
const el = /** @type {LionOption} */ (await fixture(html`
|
||||
<lion-option
|
||||
.choiceValue=${10}
|
||||
@active-changed="${/** @type {function} */ (activeSpy)}"
|
||||
></lion-option>
|
||||
`));
|
||||
expect(activeSpy.callCount).to.equal(0);
|
||||
el.active = true;
|
||||
expect(activeSpy.callCount).to.equal(1);
|
||||
|
|
@ -97,14 +112,18 @@ describe('lion-option', () => {
|
|||
|
||||
describe('Disabled', () => {
|
||||
it('does not becomes active on [mouseenter]', async () => {
|
||||
const el = await fixture(html`<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;
|
||||
el.dispatchEvent(new Event('mouseenter'));
|
||||
expect(el.active).to.be.false;
|
||||
});
|
||||
|
||||
it('does not become checked on [click]', async () => {
|
||||
const el = await fixture(html`<lion-option .choiceValue=${10} disabled></lion-option>`);
|
||||
const el = /** @type {LionOption} */ (await fixture(
|
||||
html`<lion-option .choiceValue=${10} disabled></lion-option>`,
|
||||
));
|
||||
expect(el.checked).to.be.false;
|
||||
el.click();
|
||||
await el.updateComplete;
|
||||
|
|
@ -112,9 +131,9 @@ describe('lion-option', () => {
|
|||
});
|
||||
|
||||
it('does not become un-active on [mouseleave]', async () => {
|
||||
const el = await fixture(html`
|
||||
const el = /** @type {LionOption} */ (await fixture(html`
|
||||
<lion-option .choiceValue=${10} active disabled></lion-option>
|
||||
`);
|
||||
`));
|
||||
expect(el.active).to.be.true;
|
||||
el.dispatchEvent(new Event('mouseleave'));
|
||||
expect(el.active).to.be.true;
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { LionOptions } from '../src/LionOptions.js';
|
||||
import '../lion-options.js';
|
||||
|
||||
describe('lion-options', () => {
|
||||
it('should have role="listbox"', async () => {
|
||||
const registrationTargetEl = document.createElement('div');
|
||||
const el = await fixture(html`
|
||||
const el = /** @type {LionOptions} */ (await fixture(html`
|
||||
<lion-options .registrationTarget=${registrationTargetEl}></lion-options>
|
||||
`);
|
||||
`));
|
||||
expect(el.role).to.equal('listbox');
|
||||
});
|
||||
});
|
||||
5
packages/listbox/types/LionOption.d.ts
vendored
Normal file
5
packages/listbox/types/LionOption.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ChoiceGroupHost } from '@lion/form-core/types/choice-group/ChoiceGroupMixinTypes';
|
||||
|
||||
export declare class LionOptionHost {
|
||||
private __parentFormGroup: ChoiceGroupHost;
|
||||
}
|
||||
83
packages/listbox/types/ListboxMixinTypes.d.ts
vendored
Normal file
83
packages/listbox/types/ListboxMixinTypes.d.ts
vendored
Normal 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;
|
||||
|
|
@ -13,8 +13,8 @@ import { html } from '@lion/core';
|
|||
import { Required } from '@lion/form-core';
|
||||
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
|
||||
|
||||
import './lion-option.js';
|
||||
import './lion-options.js';
|
||||
import '@lion/listbox/lion-option.js';
|
||||
import '@lion/listbox/lion-options.js';
|
||||
import './lion-select-rich.js';
|
||||
|
||||
export default {
|
||||
|
|
@ -55,8 +55,8 @@ npm i --save @lion/select-rich
|
|||
import { LionSelectRich, LionOptions, LionOption } from '@lion/select-rich';
|
||||
// or
|
||||
import '@lion/select-rich/lion-select-rich.js';
|
||||
import '@lion/select-rich/lion-options.js';
|
||||
import '@lion/select-rich/lion-option.js';
|
||||
import '@lion/listbox/lion-options.js';
|
||||
import '@lion/listbox/lion-option.js';
|
||||
```
|
||||
|
||||
> 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() {
|
||||
if (this.selectedElement) {
|
||||
const labelNodes = Array.from(this.selectedElement.querySelectorAll('*'));
|
||||
const labelNodes = Array.from(this.selectedElement.childNodes);
|
||||
// Nested html in the selected option
|
||||
if (labelNodes.length > 0) {
|
||||
// Cloning is important if you plan on passing nodes straight to a lit template
|
||||
|
|
|
|||
|
|
@ -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 { LionSelectInvoker } from './src/LionSelectInvoker.js';
|
||||
export { LionOptions } from './src/LionOptions.js';
|
||||
export { LionOption } from './src/LionOption.js';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { LionOption } from './src/LionOption.js';
|
||||
|
||||
customElements.define('lion-option', LionOption);
|
||||
/**
|
||||
* @deprecated
|
||||
* Import here for backwards compatibility
|
||||
*/
|
||||
import '@lion/listbox/lion-option.js';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { LionOptions } from './src/LionOptions.js';
|
||||
|
||||
customElements.define('lion-options', LionOptions);
|
||||
/**
|
||||
* @deprecated
|
||||
* Import here for backwards compatibility
|
||||
*/
|
||||
import '@lion/listbox/lion-options.js';
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"@lion/button": "0.7.12",
|
||||
"@lion/core": "0.11.0",
|
||||
"@lion/form-core": "0.6.0",
|
||||
"@lion/listbox": "0.0.0",
|
||||
"@lion/overlays": "0.18.0"
|
||||
},
|
||||
"keywords": [
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { LionButton } from '@lion/button';
|
||||
import { LionButton } from '@lion/button/src/LionButton.js';
|
||||
import { css, html } from '@lion/core';
|
||||
|
||||
/**
|
||||
* LionSelectInvoker: invoker button consuming a selected element
|
||||
*
|
||||
* @customElement lion-select-invoker
|
||||
* @extends {LionButton}
|
||||
* @typedef {import('@lion/core').CSSResult} CSSResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* LionSelectInvoker: invoker button consuming a selected element
|
||||
*/
|
||||
// @ts-expect-error static get sryles return type
|
||||
export class LionSelectInvoker extends LionButton {
|
||||
static get styles() {
|
||||
return [
|
||||
|
|
@ -21,25 +23,15 @@ export class LionSelectInvoker extends LionButton {
|
|||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* @desc the option Element that is currently selected
|
||||
*/
|
||||
...super.properties,
|
||||
selectedElement: {
|
||||
type: Object,
|
||||
},
|
||||
/**
|
||||
* @desc When the connected LionSelectRich instance is readOnly,
|
||||
* this should be reflected in the invoker as well
|
||||
*/
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'readonly',
|
||||
},
|
||||
/**
|
||||
* @desc When the connected LionSelectRich instance has only one option,
|
||||
* this should be reflected in the invoker as well
|
||||
*/
|
||||
singleOption: {
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
|
|
@ -54,43 +46,56 @@ export class LionSelectInvoker extends LionButton {
|
|||
after: () => {
|
||||
const icon = document.createElement('span');
|
||||
icon.textContent = '▼';
|
||||
icon.setAttribute('role', 'img');
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
return icon;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get _contentWrapperNode() {
|
||||
return this.shadowRoot.getElementById('content-wrapper');
|
||||
return /** @type {ShadowRoot} */ (this.shadowRoot).getElementById('content-wrapper');
|
||||
}
|
||||
|
||||
constructor() {
|
||||
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;
|
||||
/**
|
||||
* When the connected LionSelectRich instance has only one option,
|
||||
* this should be reflected in the invoker as well
|
||||
*/
|
||||
this.singleOption = false;
|
||||
this.type = 'button';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (super.connectedCallback) {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
const handleKeydown = event => {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
__handleKeydown(/** @type {KeyboardEvent} */ event) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
/* no default */
|
||||
}
|
||||
};
|
||||
this.handleKeydown = handleKeydown;
|
||||
this.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('keydown', this.__handleKeydown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (super.disconnectedCallback) {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
this.removeEventListener('keydown', this.handleKeydown);
|
||||
this.removeEventListener('keydown', this.__handleKeydown);
|
||||
}
|
||||
|
||||
_contentTemplate() {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
import {
|
||||
ChoiceGroupMixin,
|
||||
FormControlMixin,
|
||||
FormRegistrarMixin,
|
||||
InteractionStateMixin,
|
||||
ValidateMixin,
|
||||
} from '@lion/form-core';
|
||||
import { css, html, LitElement, ScopedElementsMixin, SlotMixin } from '@lion/core';
|
||||
|
||||
import { LionListbox } from '@lion/listbox';
|
||||
import { html, ScopedElementsMixin, SlotMixin } from '@lion/core';
|
||||
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
|
||||
import './differentKeyNamesShimIE.js';
|
||||
import '@lion/core/src/differentKeyEventNamesShimIE.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() {
|
||||
if (navigator.appVersion.indexOf('Mac') !== -1) {
|
||||
|
|
@ -22,41 +19,11 @@ function detectInteractionMode() {
|
|||
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
|
||||
*
|
||||
* @customElement lion-select-rich
|
||||
* @extends {LitElement}
|
||||
*/
|
||||
export class LionSelectRich extends ScopedElementsMixin(
|
||||
ChoiceGroupMixin(
|
||||
OverlayMixin(
|
||||
FormRegistrarMixin(
|
||||
InteractionStateMixin(ValidateMixin(FormControlMixin(SlotMixin(LitElement)))),
|
||||
),
|
||||
),
|
||||
),
|
||||
) {
|
||||
// @ts-expect-error base constructors same return type
|
||||
export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) {
|
||||
static get scopedElements() {
|
||||
return {
|
||||
...super.scopedElements,
|
||||
|
|
@ -66,174 +33,109 @@ export class LionSelectRich extends ScopedElementsMixin(
|
|||
|
||||
static get properties() {
|
||||
return {
|
||||
disabled: {
|
||||
navigateWithinInvoker: {
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'navigate-within-invoker',
|
||||
},
|
||||
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'readonly',
|
||||
},
|
||||
|
||||
interactionMode: {
|
||||
type: String,
|
||||
attribute: 'interaction-mode',
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
singleOption: {
|
||||
type: Boolean,
|
||||
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() {
|
||||
return {
|
||||
...super.slots,
|
||||
invoker: () =>
|
||||
document.createElement(this.constructor.getScopedTagName('lion-select-invoker')),
|
||||
invoker: () => document.createElement(LionSelectRich.getScopedTagName('lion-select-invoker')),
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {LionSelectInvoker} */
|
||||
get _invokerNode() {
|
||||
return 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);
|
||||
return /** @type {LionSelectInvoker} */ (Array.from(this.children).find(
|
||||
child => child.slot === 'invoker',
|
||||
));
|
||||
}
|
||||
|
||||
get _scrollTargetNode() {
|
||||
// @ts-expect-error _scrollTargetNode not on type
|
||||
return this._overlayContentNode._scrollTargetNode || this._overlayContentNode;
|
||||
}
|
||||
|
||||
set activeIndex(index) {
|
||||
if (this.formElements[index]) {
|
||||
const el = this.formElements[index];
|
||||
el.active = true;
|
||||
get checkedIndex() {
|
||||
return /** @type {number} */ (super.checkedIndex);
|
||||
}
|
||||
|
||||
if (!isInView(this._scrollTargetNode, el)) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
set checkedIndex(i) {
|
||||
super.checkedIndex = i;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
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);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// need to do this before anything else
|
||||
this._listboxNode.registrationTarget = this;
|
||||
if (super.connectedCallback) {
|
||||
super.connectedCallback();
|
||||
}
|
||||
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
|
||||
this.__setupInvokerNode();
|
||||
this.__setupListboxNode();
|
||||
this.__setupEventListeners();
|
||||
|
||||
this.__toggleInvokerDisabled();
|
||||
|
||||
if (this._labelNode) {
|
||||
this._labelNode.addEventListener('click', this.__focusInvokerOnLabelClick);
|
||||
}
|
||||
|
||||
this.registrationComplete.then(() => {
|
||||
this.__initInteractionStates();
|
||||
});
|
||||
this.addEventListener('keyup', this.__onKeyUp);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (super.disconnectedCallback) {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
if (this._labelNode) {
|
||||
this._labelNode.removeEventListener('click', this.__toggleChecked);
|
||||
this._labelNode.removeEventListener('click', this.__focusInvokerOnLabelClick);
|
||||
}
|
||||
this._scrollTargetNode.removeEventListener('keydown', this.__overlayOnHide);
|
||||
this.__teardownInvokerNode();
|
||||
this.__teardownListboxNode();
|
||||
this.__teardownEventListeners();
|
||||
this.removeEventListener('keyup', this.__onKeyUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {unknown} oldValue
|
||||
*/
|
||||
requestUpdateInternal(name, oldValue) {
|
||||
super.requestUpdateInternal(name, oldValue);
|
||||
if (name === 'interactionMode') {
|
||||
if (this.interactionMode === 'auto') {
|
||||
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();
|
||||
}
|
||||
|
||||
get _inputNode() {
|
||||
// In FormControl, we get direct child [slot="input"]. This doesn't work, because the overlay
|
||||
// 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>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -299,13 +187,13 @@ export class LionSelectRich extends ScopedElementsMixin(
|
|||
if (changedProperties.has('_ariaDescribedNodes')) {
|
||||
this._invokerNode.setAttribute(
|
||||
'aria-describedby',
|
||||
this._inputNode.getAttribute('aria-describedby'),
|
||||
/** @type {string} */ (this._inputNode.getAttribute('aria-describedby')),
|
||||
);
|
||||
}
|
||||
|
||||
if (changedProperties.has('showsFeedbackFor')) {
|
||||
// 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() {
|
||||
this.opened = !this.opened;
|
||||
}
|
||||
|
|
@ -321,7 +210,7 @@ export class LionSelectRich extends ScopedElementsMixin(
|
|||
/**
|
||||
* @override
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
_inputGroupInputTemplate() {
|
||||
return html`
|
||||
<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() {
|
||||
if (this._invokerNode) {
|
||||
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() {
|
||||
// sync to invoker
|
||||
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() {
|
||||
this._invokerNode.id = `invoker-${this._inputId}`;
|
||||
this._invokerNode.setAttribute('aria-haspopup', 'listbox');
|
||||
|
|
@ -578,17 +244,19 @@ export class LionSelectRich extends ScopedElementsMixin(
|
|||
this.__setupInvokerNodeEventListener();
|
||||
}
|
||||
|
||||
__setupInvokerNodeEventListener() {
|
||||
this.__invokerOnClick = () => {
|
||||
if (!this.disabled && !this.readOnly && !this.singleOption) {
|
||||
__invokerOnClick() {
|
||||
if (!this.disabled && !this.readOnly && !this.singleOption && !this.__blockListShow) {
|
||||
this._overlayCtrl.toggle();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
__invokerOnBlur() {
|
||||
this.dispatchEvent(new Event('blur'));
|
||||
}
|
||||
|
||||
__setupInvokerNodeEventListener() {
|
||||
this._invokerNode.addEventListener('click', this.__invokerOnClick);
|
||||
|
||||
this.__invokerOnBlur = () => {
|
||||
this.dispatchEvent(new Event('blur'));
|
||||
};
|
||||
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
|
||||
* 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.
|
||||
* @override OverlayMixin
|
||||
*/
|
||||
__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
|
||||
_defineOverlayConfig() {
|
||||
return {
|
||||
|
|
@ -653,36 +283,39 @@ export class LionSelectRich extends ScopedElementsMixin(
|
|||
*/
|
||||
_noDefaultSelectedInheritsWidth() {
|
||||
if (this.checkedIndex === -1) {
|
||||
this._overlayCtrl.inheritsReferenceWidth = 'min';
|
||||
this._overlayCtrl.updateConfig({ inheritsReferenceWidth: 'min' });
|
||||
} 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() {
|
||||
super._setupOverlayCtrl();
|
||||
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('show', this.__overlayOnShow);
|
||||
|
||||
this.__overlayOnHide = () => {
|
||||
this._invokerNode.focus();
|
||||
};
|
||||
this._overlayCtrl.addEventListener('hide', this.__overlayOnHide);
|
||||
|
||||
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
|
||||
this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
|
||||
}
|
||||
|
||||
_teardownOverlayCtrl() {
|
||||
|
|
@ -692,21 +325,6 @@ export class LionSelectRich extends ScopedElementsMixin(
|
|||
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() {
|
||||
this._invokerNode.focus();
|
||||
}
|
||||
|
|
@ -725,14 +343,103 @@ export class LionSelectRich extends ScopedElementsMixin(
|
|||
return this._listboxNode;
|
||||
}
|
||||
|
||||
set fieldName(value) {
|
||||
this.__fieldName = value;
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
__onKeyUp(ev) {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
get fieldName() {
|
||||
const label =
|
||||
this.label ||
|
||||
(this.querySelector('[slot=label]') && this.querySelector('[slot=label]').textContent);
|
||||
return this.__fieldName || label || this.name;
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc
|
||||
* Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
|
||||
* an item.
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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' });
|
||||
});
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { OverlayMixin } from '@lion/overlays';
|
||||
import { LitElement } from 'lit-element';
|
||||
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-options.js';
|
||||
import '../lion-option.js';
|
||||
|
||||
const tagString = defineCE(
|
||||
class extends OverlayMixin(LitElement) {
|
||||
|
|
|
|||
|
|
@ -1,105 +1,13 @@
|
|||
import { Required } from '@lion/form-core';
|
||||
import { expect, html, triggerBlurFor, triggerFocusFor, fixture } from '@open-wc/testing';
|
||||
|
||||
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 './keyboardEventShimIE.js';
|
||||
|
||||
describe('lion-select-rich interactions', () => {
|
||||
describe('values', () => {
|
||||
it('registers options', async () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich>
|
||||
<lion-options slot="input">
|
||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
||||
</lion-options>
|
||||
</lion-select-rich>
|
||||
`);
|
||||
expect(el.formElements.length).to.equal(2);
|
||||
expect(el.formElements).to.eql([
|
||||
el.querySelectorAll('lion-option')[0],
|
||||
el.querySelectorAll('lion-option')[1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('has the first element by default checked and active', async () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich>
|
||||
<lion-options slot="input">
|
||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
||||
</lion-options>
|
||||
</lion-select-rich>
|
||||
`);
|
||||
|
||||
expect(el.querySelector('lion-option').checked).to.be.true;
|
||||
expect(el.querySelector('lion-option').active).to.be.true;
|
||||
expect(el.modelValue).to.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', () => {
|
||||
it('does not allow to navigate above the first or below the last option', async () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich opened>
|
||||
<lion-options slot="input">
|
||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||
</lion-options>
|
||||
</lion-select-rich>
|
||||
`);
|
||||
expect(() => {
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
||||
}).to.not.throw();
|
||||
expect(el.checkedIndex).to.equal(0);
|
||||
expect(el.activeIndex).to.equal(0);
|
||||
});
|
||||
|
||||
it('navigates to first and last option with [Home] and [End] keys', async () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich opened interaction-mode="windows/linux">
|
||||
|
|
@ -113,44 +21,12 @@ describe('lion-select-rich interactions', () => {
|
|||
`);
|
||||
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);
|
||||
|
||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'End' }));
|
||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
|
||||
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', () => {
|
||||
|
|
@ -180,12 +56,12 @@ describe('lion-select-rich interactions', () => {
|
|||
expect(el.checkedIndex).to.equal(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.checkedIndex).to.equal(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.checkedIndex).to.equal(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', () => {
|
||||
it('cannot be focused if disabled', async () => {
|
||||
const el = await fixture(html`
|
||||
|
|
@ -260,32 +112,6 @@ describe('lion-select-rich interactions', () => {
|
|||
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 () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich disabled>
|
||||
|
|
@ -307,194 +133,9 @@ describe('lion-select-rich interactions', () => {
|
|||
await el.updateComplete;
|
||||
expect(el._invokerNode.hasAttribute('disabled')).to.be.false;
|
||||
});
|
||||
|
||||
it('skips disabled options while navigating through list with [ArrowDown] [ArrowUp] keys', async () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich opened>
|
||||
<lion-options slot="input">
|
||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||
<lion-option .choiceValue=${20} disabled>Item 2</lion-option>
|
||||
<lion-option .choiceValue=${30}>Item 3</lion-option>
|
||||
</lion-options>
|
||||
</lion-select-rich>
|
||||
`);
|
||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
||||
expect(el.activeIndex).to.equal(2);
|
||||
|
||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
|
||||
expect(el.activeIndex).to.equal(0);
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
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 () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich>
|
||||
|
|
@ -509,114 +150,9 @@ describe('lion-select-rich interactions', () => {
|
|||
await triggerBlurFor(el._invokerNode);
|
||||
expect(el.touched).to.be.true;
|
||||
});
|
||||
|
||||
it('is prefilled if there is a value on init', async () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich>
|
||||
<lion-options slot="input">
|
||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||
</lion-options>
|
||||
</lion-select-rich>
|
||||
`);
|
||||
expect(el.prefilled).to.be.true;
|
||||
|
||||
const elEmpty = await fixture(html`
|
||||
<lion-select-rich>
|
||||
<lion-options slot="input">
|
||||
<lion-option .choiceValue=${null}>Please select a value</lion-option>
|
||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||
</lion-options>
|
||||
</lion-select-rich>
|
||||
`);
|
||||
expect(elEmpty.prefilled).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('can be required', async () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich .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', () => {
|
||||
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 () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich .validators=${[new Required()]}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { LitElement } from '@lion/core';
|
||||
import { OverlayController } from '@lion/overlays';
|
||||
import { Required } from '@lion/form-core';
|
||||
import {
|
||||
aTimeout,
|
||||
defineCE,
|
||||
|
|
@ -11,111 +10,13 @@ import {
|
|||
fixture,
|
||||
} from '@open-wc/testing';
|
||||
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 './keyboardEventShimIE.js';
|
||||
|
||||
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 () => {
|
||||
const el = await fixture(html`
|
||||
<lion-select-rich label="foo">
|
||||
|
|
@ -127,119 +28,6 @@ describe('lion-select-rich', () => {
|
|||
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', () => {
|
||||
it('generates an lion-select-invoker if no invoker is provided', async () => {
|
||||
const el = await fixture(html`
|
||||
|
|
@ -454,7 +242,7 @@ describe('lion-select-rich', () => {
|
|||
`);
|
||||
|
||||
// The default is min, so we override that behavior here
|
||||
el._overlayCtrl.inheritsReferenceWidth = 'full';
|
||||
el._overlayCtrl.updateConfig({ inheritsReferenceWidth: 'full' });
|
||||
el._initialInheritsReferenceWidth = 'full';
|
||||
|
||||
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full');
|
||||
|
|
@ -486,7 +274,7 @@ describe('lion-select-rich', () => {
|
|||
|
||||
elSingleoption._invokerNode.click();
|
||||
await elSingleoption.updateComplete;
|
||||
expect(elSingleoption.singleOption).to.be.undefined;
|
||||
expect(elSingleoption.singleOption).to.be.false;
|
||||
|
||||
const optionELm = elSingleoption.querySelectorAll('lion-option')[0];
|
||||
optionELm.parentNode.removeChild(optionELm);
|
||||
|
|
@ -538,7 +326,7 @@ describe('lion-select-rich', () => {
|
|||
<lion-options slot="input"></lion-options>
|
||||
</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;
|
||||
});
|
||||
|
||||
|
|
@ -588,7 +376,7 @@ describe('lion-select-rich', () => {
|
|||
<lion-options slot="input"></lion-options>
|
||||
</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;
|
||||
});
|
||||
});
|
||||
|
|
@ -608,7 +396,7 @@ describe('lion-select-rich', () => {
|
|||
el.activeIndex = 1;
|
||||
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.checkedIndex).to.equal(1);
|
||||
});
|
||||
|
|
@ -667,71 +455,9 @@ describe('lion-select-rich', () => {
|
|||
|
||||
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', () => {
|
||||
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 () => {
|
||||
const mySelectContainerTagString = defineCE(
|
||||
class extends LitElement {
|
||||
|
|
@ -880,24 +606,4 @@ describe('lion-select-rich', () => {
|
|||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@
|
|||
"packages/form-core/**/*.js",
|
||||
"packages/overlays/**/*.js",
|
||||
"packages/tooltip/**/*.js",
|
||||
"packages/button/src/**/*.js"
|
||||
"packages/button/src/**/*.js",
|
||||
"packages/listbox/src/*.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
|
|
|||
Loading…
Reference in a new issue