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/button': patch
|
||||||
'@lion/overlays': patch
|
'@lion/overlays': minor
|
||||||
'@lion/tooltip': patch
|
'@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 { ChoiceInputMixin, FormRegisteringMixin } from '@lion/form-core';
|
||||||
import { css, DisabledMixin, html, LitElement } from '@lion/core';
|
import { css, DisabledMixin, html, LitElement } from '@lion/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('@lion/form-core/types/choice-group/ChoiceGroupMixinTypes').ChoiceGroupHost } ChoiceGroupHost
|
||||||
|
* @typedef {import('../types/LionOption').LionOptionHost } LionOptionHost
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option
|
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option
|
||||||
* Can be a child of datalist/select, or role="listbox"
|
* Can be a child of datalist/select, or role="listbox"
|
||||||
|
|
@ -8,6 +13,7 @@ import { css, DisabledMixin, html, LitElement } from '@lion/core';
|
||||||
* Element gets state supplied externally, reflects this to attributes,
|
* Element gets state supplied externally, reflects this to attributes,
|
||||||
* enabling SubClassers to style based on those states
|
* enabling SubClassers to style based on those states
|
||||||
*/
|
*/
|
||||||
|
// @ts-expect-error
|
||||||
export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMixin(LitElement))) {
|
export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMixin(LitElement))) {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -15,10 +21,6 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
reflect: true,
|
reflect: true,
|
||||||
},
|
},
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
reflect: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,14 +31,17 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
||||||
display: block;
|
display: block;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host([hidden]) {
|
:host([hidden]) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host([active]),
|
|
||||||
:host(:hover) {
|
:host(:hover) {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
:host([active]) {
|
||||||
background-color: #ddd;
|
background-color: #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +70,10 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
||||||
this.__registerEventListeners();
|
this.__registerEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {unknown} oldValue
|
||||||
|
*/
|
||||||
requestUpdateInternal(name, oldValue) {
|
requestUpdateInternal(name, oldValue) {
|
||||||
super.requestUpdateInternal(name, oldValue);
|
super.requestUpdateInternal(name, oldValue);
|
||||||
|
|
||||||
|
|
@ -73,6 +82,9 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
|
*/
|
||||||
updated(changedProperties) {
|
updated(changedProperties) {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
if (changedProperties.has('checked')) {
|
if (changedProperties.has('checked')) {
|
||||||
|
|
@ -98,15 +110,22 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
||||||
}
|
}
|
||||||
|
|
||||||
__registerEventListeners() {
|
__registerEventListeners() {
|
||||||
this.__onClick = () => {
|
|
||||||
if (!this.disabled) {
|
|
||||||
this.checked = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.addEventListener('click', this.__onClick);
|
this.addEventListener('click', this.__onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
__unRegisterEventListeners() {
|
__unRegisterEventListeners() {
|
||||||
this.removeEventListener('click', this.__onClick);
|
this.removeEventListener('click', this.__onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
__onClick = () => {
|
||||||
|
if (this.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parentForm = /** @type {unknown} */ (this.__parentFormGroup);
|
||||||
|
if (parentForm && /** @type {ChoiceGroupHost} */ (parentForm).multipleChoice) {
|
||||||
|
this.checked = !this.checked;
|
||||||
|
} else {
|
||||||
|
this.checked = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -3,9 +3,6 @@ import { FormRegistrarPortalMixin } from '@lion/form-core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LionOptions
|
* LionOptions
|
||||||
*
|
|
||||||
* @customElement lion-options
|
|
||||||
* @extends {LitElement}
|
|
||||||
*/
|
*/
|
||||||
export class LionOptions extends FormRegistrarPortalMixin(LitElement) {
|
export class LionOptions extends FormRegistrarPortalMixin(LitElement) {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
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 { expect, fixture, html } from '@open-wc/testing';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
import { LionOption } from '../src/LionOption.js';
|
||||||
import '../lion-option.js';
|
import '../lion-option.js';
|
||||||
|
|
||||||
describe('lion-option', () => {
|
describe('lion-option', () => {
|
||||||
describe('Values', () => {
|
describe('Values', () => {
|
||||||
it('has a modelValue', async () => {
|
it('has a modelValue', async () => {
|
||||||
const el = await fixture(html`<lion-option .choiceValue=${10}></lion-option>`);
|
const el = /** @type {LionOption} */ (await fixture(
|
||||||
|
html`<lion-option .choiceValue=${10}></lion-option>`,
|
||||||
|
));
|
||||||
expect(el.modelValue).to.deep.equal({ value: 10, checked: false });
|
expect(el.modelValue).to.deep.equal({ value: 10, checked: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can be checked', async () => {
|
it('can be checked', async () => {
|
||||||
const el = await fixture(html`<lion-option .choiceValue=${10} checked></lion-option>`);
|
const el = /** @type {LionOption} */ (await fixture(
|
||||||
|
html`<lion-option .choiceValue=${10} checked></lion-option>`,
|
||||||
|
));
|
||||||
expect(el.modelValue).to.deep.equal({ value: 10, checked: true });
|
expect(el.modelValue).to.deep.equal({ value: 10, checked: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is hidden when attribute hidden is true', async () => {
|
it('is hidden when attribute hidden is true', async () => {
|
||||||
const el = await fixture(html`<lion-option .choiceValue=${10} hidden></lion-option>`);
|
const el = /** @type {LionOption} */ (await fixture(
|
||||||
|
html`<lion-option .choiceValue=${10} hidden></lion-option>`,
|
||||||
|
));
|
||||||
expect(el).not.to.be.displayed;
|
expect(el).not.to.be.displayed;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
describe('Accessibility', () => {
|
||||||
it('has the "option" role', async () => {
|
it('has the "option" role', async () => {
|
||||||
const el = await fixture(html`<lion-option></lion-option>`);
|
const el = /** @type {LionOption} */ (await fixture(html`<lion-option></lion-option>`));
|
||||||
expect(el.getAttribute('role')).to.equal('option');
|
expect(el.getAttribute('role')).to.equal('option');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has "aria-selected" attribute when checked', async () => {
|
it('has "aria-selected" attribute when checked', async () => {
|
||||||
const el = await fixture(html`
|
const el = /** @type {LionOption} */ (await fixture(html`
|
||||||
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
|
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
|
||||||
`);
|
`));
|
||||||
expect(el.getAttribute('aria-selected')).to.equal('true');
|
expect(el.getAttribute('aria-selected')).to.equal('true');
|
||||||
|
|
||||||
el.checked = false;
|
el.checked = false;
|
||||||
|
|
@ -41,9 +49,9 @@ describe('lion-option', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asynchronously adds the attributes "aria-disabled" and "disabled" when disabled', async () => {
|
it('asynchronously adds the attributes "aria-disabled" and "disabled" when disabled', async () => {
|
||||||
const el = await fixture(html`
|
const el = /** @type {LionOption} */ (await fixture(html`
|
||||||
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
|
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
|
||||||
`);
|
`));
|
||||||
expect(el.getAttribute('aria-disabled')).to.equal('true');
|
expect(el.getAttribute('aria-disabled')).to.equal('true');
|
||||||
expect(el.hasAttribute('disabled')).to.be.true;
|
expect(el.hasAttribute('disabled')).to.be.true;
|
||||||
|
|
||||||
|
|
@ -59,7 +67,9 @@ describe('lion-option', () => {
|
||||||
|
|
||||||
describe('State reflection', () => {
|
describe('State reflection', () => {
|
||||||
it('asynchronously adds the attribute "active" when active', async () => {
|
it('asynchronously adds the attribute "active" when active', async () => {
|
||||||
const el = await fixture(html`<lion-option .choiceValue=${10}></lion-option>`);
|
const el = /** @type {LionOption} */ (await fixture(
|
||||||
|
html`<lion-option .choiceValue=${10}></lion-option>`,
|
||||||
|
));
|
||||||
expect(el.active).to.equal(false);
|
expect(el.active).to.equal(false);
|
||||||
expect(el.hasAttribute('active')).to.be.false;
|
expect(el.hasAttribute('active')).to.be.false;
|
||||||
|
|
||||||
|
|
@ -77,7 +87,9 @@ describe('lion-option', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does become checked on [click]', async () => {
|
it('does become checked on [click]', async () => {
|
||||||
const el = await fixture(html`<lion-option .choiceValue=${10}></lion-option>`);
|
const el = /** @type {LionOption} */ (await fixture(
|
||||||
|
html`<lion-option .choiceValue=${10}></lion-option>`,
|
||||||
|
));
|
||||||
expect(el.checked).to.be.false;
|
expect(el.checked).to.be.false;
|
||||||
el.click();
|
el.click();
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
@ -86,9 +98,12 @@ describe('lion-option', () => {
|
||||||
|
|
||||||
it('fires active-changed event', async () => {
|
it('fires active-changed event', async () => {
|
||||||
const activeSpy = sinon.spy();
|
const activeSpy = sinon.spy();
|
||||||
const el = await fixture(html`
|
const el = /** @type {LionOption} */ (await fixture(html`
|
||||||
<lion-option .choiceValue=${10} @active-changed="${activeSpy}"></lion-option>
|
<lion-option
|
||||||
`);
|
.choiceValue=${10}
|
||||||
|
@active-changed="${/** @type {function} */ (activeSpy)}"
|
||||||
|
></lion-option>
|
||||||
|
`));
|
||||||
expect(activeSpy.callCount).to.equal(0);
|
expect(activeSpy.callCount).to.equal(0);
|
||||||
el.active = true;
|
el.active = true;
|
||||||
expect(activeSpy.callCount).to.equal(1);
|
expect(activeSpy.callCount).to.equal(1);
|
||||||
|
|
@ -97,14 +112,18 @@ describe('lion-option', () => {
|
||||||
|
|
||||||
describe('Disabled', () => {
|
describe('Disabled', () => {
|
||||||
it('does not becomes active on [mouseenter]', async () => {
|
it('does not becomes active on [mouseenter]', async () => {
|
||||||
const el = await fixture(html`<lion-option .choiceValue=${10} disabled></lion-option>`);
|
const el = /** @type {LionOption} */ (await fixture(
|
||||||
|
html`<lion-option .choiceValue=${10} disabled></lion-option>`,
|
||||||
|
));
|
||||||
expect(el.active).to.be.false;
|
expect(el.active).to.be.false;
|
||||||
el.dispatchEvent(new Event('mouseenter'));
|
el.dispatchEvent(new Event('mouseenter'));
|
||||||
expect(el.active).to.be.false;
|
expect(el.active).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not become checked on [click]', async () => {
|
it('does not become checked on [click]', async () => {
|
||||||
const el = await fixture(html`<lion-option .choiceValue=${10} disabled></lion-option>`);
|
const el = /** @type {LionOption} */ (await fixture(
|
||||||
|
html`<lion-option .choiceValue=${10} disabled></lion-option>`,
|
||||||
|
));
|
||||||
expect(el.checked).to.be.false;
|
expect(el.checked).to.be.false;
|
||||||
el.click();
|
el.click();
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
@ -112,9 +131,9 @@ describe('lion-option', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not become un-active on [mouseleave]', async () => {
|
it('does not become un-active on [mouseleave]', async () => {
|
||||||
const el = await fixture(html`
|
const el = /** @type {LionOption} */ (await fixture(html`
|
||||||
<lion-option .choiceValue=${10} active disabled></lion-option>
|
<lion-option .choiceValue=${10} active disabled></lion-option>
|
||||||
`);
|
`));
|
||||||
expect(el.active).to.be.true;
|
expect(el.active).to.be.true;
|
||||||
el.dispatchEvent(new Event('mouseleave'));
|
el.dispatchEvent(new Event('mouseleave'));
|
||||||
expect(el.active).to.be.true;
|
expect(el.active).to.be.true;
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { expect, fixture, html } from '@open-wc/testing';
|
import { expect, fixture, html } from '@open-wc/testing';
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
import { LionOptions } from '../src/LionOptions.js';
|
||||||
import '../lion-options.js';
|
import '../lion-options.js';
|
||||||
|
|
||||||
describe('lion-options', () => {
|
describe('lion-options', () => {
|
||||||
it('should have role="listbox"', async () => {
|
it('should have role="listbox"', async () => {
|
||||||
const registrationTargetEl = document.createElement('div');
|
const registrationTargetEl = document.createElement('div');
|
||||||
const el = await fixture(html`
|
const el = /** @type {LionOptions} */ (await fixture(html`
|
||||||
<lion-options .registrationTarget=${registrationTargetEl}></lion-options>
|
<lion-options .registrationTarget=${registrationTargetEl}></lion-options>
|
||||||
`);
|
`));
|
||||||
expect(el.role).to.equal('listbox');
|
expect(el.role).to.equal('listbox');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
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 { Required } from '@lion/form-core';
|
||||||
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
|
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
|
||||||
|
|
||||||
import './lion-option.js';
|
import '@lion/listbox/lion-option.js';
|
||||||
import './lion-options.js';
|
import '@lion/listbox/lion-options.js';
|
||||||
import './lion-select-rich.js';
|
import './lion-select-rich.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -55,8 +55,8 @@ npm i --save @lion/select-rich
|
||||||
import { LionSelectRich, LionOptions, LionOption } from '@lion/select-rich';
|
import { LionSelectRich, LionOptions, LionOption } from '@lion/select-rich';
|
||||||
// or
|
// or
|
||||||
import '@lion/select-rich/lion-select-rich.js';
|
import '@lion/select-rich/lion-select-rich.js';
|
||||||
import '@lion/select-rich/lion-options.js';
|
import '@lion/listbox/lion-options.js';
|
||||||
import '@lion/select-rich/lion-option.js';
|
import '@lion/listbox/lion-option.js';
|
||||||
```
|
```
|
||||||
|
|
||||||
> No need to npm install `@lion/option` separately, it comes with the rich select as a dependency
|
> No need to npm install `@lion/option` separately, it comes with the rich select as a dependency
|
||||||
|
|
@ -385,7 +385,7 @@ class MyInvokerButton extends LitElement() {
|
||||||
|
|
||||||
_contentTemplate() {
|
_contentTemplate() {
|
||||||
if (this.selectedElement) {
|
if (this.selectedElement) {
|
||||||
const labelNodes = Array.from(this.selectedElement.querySelectorAll('*'));
|
const labelNodes = Array.from(this.selectedElement.childNodes);
|
||||||
// Nested html in the selected option
|
// Nested html in the selected option
|
||||||
if (labelNodes.length > 0) {
|
if (labelNodes.length > 0) {
|
||||||
// Cloning is important if you plan on passing nodes straight to a lit template
|
// Cloning is important if you plan on passing nodes straight to a lit template
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Import here for backwards compatibility
|
||||||
|
*/
|
||||||
|
export { LionOptions } from '@lion/listbox';
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Import here for backwards compatibility
|
||||||
|
*/
|
||||||
|
export { LionOption } from '@lion/listbox';
|
||||||
export { LionSelectRich } from './src/LionSelectRich.js';
|
export { LionSelectRich } from './src/LionSelectRich.js';
|
||||||
export { LionSelectInvoker } from './src/LionSelectInvoker.js';
|
export { LionSelectInvoker } from './src/LionSelectInvoker.js';
|
||||||
export { LionOptions } from './src/LionOptions.js';
|
|
||||||
export { LionOption } from './src/LionOption.js';
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
import { LionOption } from './src/LionOption.js';
|
/**
|
||||||
|
* @deprecated
|
||||||
customElements.define('lion-option', LionOption);
|
* Import here for backwards compatibility
|
||||||
|
*/
|
||||||
|
import '@lion/listbox/lion-option.js';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
import { LionOptions } from './src/LionOptions.js';
|
/**
|
||||||
|
* @deprecated
|
||||||
customElements.define('lion-options', LionOptions);
|
* Import here for backwards compatibility
|
||||||
|
*/
|
||||||
|
import '@lion/listbox/lion-options.js';
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"@lion/button": "0.7.12",
|
"@lion/button": "0.7.12",
|
||||||
"@lion/core": "0.11.0",
|
"@lion/core": "0.11.0",
|
||||||
"@lion/form-core": "0.6.0",
|
"@lion/form-core": "0.6.0",
|
||||||
|
"@lion/listbox": "0.0.0",
|
||||||
"@lion/overlays": "0.18.0"
|
"@lion/overlays": "0.18.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { LionButton } from '@lion/button';
|
import { LionButton } from '@lion/button/src/LionButton.js';
|
||||||
import { css, html } from '@lion/core';
|
import { css, html } from '@lion/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LionSelectInvoker: invoker button consuming a selected element
|
* @typedef {import('@lion/core').CSSResult} CSSResult
|
||||||
*
|
|
||||||
* @customElement lion-select-invoker
|
|
||||||
* @extends {LionButton}
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LionSelectInvoker: invoker button consuming a selected element
|
||||||
|
*/
|
||||||
|
// @ts-expect-error static get sryles return type
|
||||||
export class LionSelectInvoker extends LionButton {
|
export class LionSelectInvoker extends LionButton {
|
||||||
static get styles() {
|
static get styles() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -21,25 +23,15 @@ export class LionSelectInvoker extends LionButton {
|
||||||
|
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
/**
|
...super.properties,
|
||||||
* @desc the option Element that is currently selected
|
|
||||||
*/
|
|
||||||
selectedElement: {
|
selectedElement: {
|
||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* @desc When the connected LionSelectRich instance is readOnly,
|
|
||||||
* this should be reflected in the invoker as well
|
|
||||||
*/
|
|
||||||
readOnly: {
|
readOnly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
reflect: true,
|
reflect: true,
|
||||||
attribute: 'readonly',
|
attribute: 'readonly',
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* @desc When the connected LionSelectRich instance has only one option,
|
|
||||||
* this should be reflected in the invoker as well
|
|
||||||
*/
|
|
||||||
singleOption: {
|
singleOption: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
reflect: true,
|
reflect: true,
|
||||||
|
|
@ -54,43 +46,56 @@ export class LionSelectInvoker extends LionButton {
|
||||||
after: () => {
|
after: () => {
|
||||||
const icon = document.createElement('span');
|
const icon = document.createElement('span');
|
||||||
icon.textContent = '▼';
|
icon.textContent = '▼';
|
||||||
|
icon.setAttribute('role', 'img');
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
return icon;
|
return icon;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get _contentWrapperNode() {
|
get _contentWrapperNode() {
|
||||||
return this.shadowRoot.getElementById('content-wrapper');
|
return /** @type {ShadowRoot} */ (this.shadowRoot).getElementById('content-wrapper');
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the connected LionSelectRich instance is readOnly,
|
||||||
|
* this should be reflected in the invoker as well
|
||||||
|
*/
|
||||||
|
this.readOnly = false;
|
||||||
|
/**
|
||||||
|
* The option Element that is currently selected
|
||||||
|
* @type {HTMLElement | null}
|
||||||
|
*/
|
||||||
this.selectedElement = null;
|
this.selectedElement = null;
|
||||||
|
/**
|
||||||
|
* When the connected LionSelectRich instance has only one option,
|
||||||
|
* this should be reflected in the invoker as well
|
||||||
|
*/
|
||||||
|
this.singleOption = false;
|
||||||
this.type = 'button';
|
this.type = 'button';
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
// eslint-disable-next-line class-methods-use-this
|
||||||
if (super.connectedCallback) {
|
__handleKeydown(/** @type {KeyboardEvent} */ event) {
|
||||||
super.connectedCallback();
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
/* no default */
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeydown = event => {
|
connectedCallback() {
|
||||||
switch (event.key) {
|
super.connectedCallback();
|
||||||
case 'ArrowDown':
|
this.addEventListener('keydown', this.__handleKeydown);
|
||||||
case 'ArrowUp':
|
|
||||||
event.preventDefault();
|
|
||||||
/* no default */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.handleKeydown = handleKeydown;
|
|
||||||
this.addEventListener('keydown', this.handleKeydown);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (super.disconnectedCallback) {
|
super.disconnectedCallback();
|
||||||
super.disconnectedCallback();
|
this.removeEventListener('keydown', this.__handleKeydown);
|
||||||
}
|
|
||||||
this.removeEventListener('keydown', this.handleKeydown);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_contentTemplate() {
|
_contentTemplate() {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
import {
|
import { LionListbox } from '@lion/listbox';
|
||||||
ChoiceGroupMixin,
|
import { html, ScopedElementsMixin, SlotMixin } from '@lion/core';
|
||||||
FormControlMixin,
|
|
||||||
FormRegistrarMixin,
|
|
||||||
InteractionStateMixin,
|
|
||||||
ValidateMixin,
|
|
||||||
} from '@lion/form-core';
|
|
||||||
import { css, html, LitElement, ScopedElementsMixin, SlotMixin } from '@lion/core';
|
|
||||||
|
|
||||||
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
|
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
|
||||||
import './differentKeyNamesShimIE.js';
|
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
||||||
import { LionSelectInvoker } from './LionSelectInvoker.js';
|
import { LionSelectInvoker } from './LionSelectInvoker.js';
|
||||||
|
|
||||||
function uuid() {
|
/**
|
||||||
return Math.random().toString(36).substr(2, 10);
|
* @typedef {import('@lion/listbox').LionOptions} LionOptions
|
||||||
}
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('@open-wc/scoped-elements/src/types').ScopedElementsHost} ScopedElementsHost
|
||||||
|
*/
|
||||||
|
|
||||||
function detectInteractionMode() {
|
function detectInteractionMode() {
|
||||||
if (navigator.appVersion.indexOf('Mac') !== -1) {
|
if (navigator.appVersion.indexOf('Mac') !== -1) {
|
||||||
|
|
@ -22,41 +19,11 @@ function detectInteractionMode() {
|
||||||
return 'windows/linux';
|
return 'windows/linux';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInView(container, element, partial = false) {
|
|
||||||
const cTop = container.scrollTop;
|
|
||||||
const cBottom = cTop + container.clientHeight;
|
|
||||||
const eTop = element.offsetTop;
|
|
||||||
const eBottom = eTop + element.clientHeight;
|
|
||||||
const isTotal = eTop >= cTop && eBottom <= cBottom;
|
|
||||||
let isPartial;
|
|
||||||
|
|
||||||
if (partial === true) {
|
|
||||||
isPartial = (eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom);
|
|
||||||
} else if (typeof partial === 'number') {
|
|
||||||
if (eTop < cTop && eBottom > cTop) {
|
|
||||||
isPartial = ((eBottom - cTop) * 100) / element.clientHeight > partial;
|
|
||||||
} else if (eBottom > cBottom && eTop < cBottom) {
|
|
||||||
isPartial = ((cBottom - eTop) * 100) / element.clientHeight > partial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isTotal || isPartial;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LionSelectRich: wraps the <lion-listbox> element
|
* LionSelectRich: wraps the <lion-listbox> element
|
||||||
*
|
|
||||||
* @customElement lion-select-rich
|
|
||||||
* @extends {LitElement}
|
|
||||||
*/
|
*/
|
||||||
export class LionSelectRich extends ScopedElementsMixin(
|
// @ts-expect-error base constructors same return type
|
||||||
ChoiceGroupMixin(
|
export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) {
|
||||||
OverlayMixin(
|
|
||||||
FormRegistrarMixin(
|
|
||||||
InteractionStateMixin(ValidateMixin(FormControlMixin(SlotMixin(LitElement)))),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
static get scopedElements() {
|
static get scopedElements() {
|
||||||
return {
|
return {
|
||||||
...super.scopedElements,
|
...super.scopedElements,
|
||||||
|
|
@ -66,174 +33,109 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
|
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
disabled: {
|
navigateWithinInvoker: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
reflect: true,
|
attribute: 'navigate-within-invoker',
|
||||||
},
|
},
|
||||||
|
|
||||||
readOnly: {
|
|
||||||
type: Boolean,
|
|
||||||
reflect: true,
|
|
||||||
attribute: 'readonly',
|
|
||||||
},
|
|
||||||
|
|
||||||
interactionMode: {
|
interactionMode: {
|
||||||
type: String,
|
type: String,
|
||||||
attribute: 'interaction-mode',
|
attribute: 'interaction-mode',
|
||||||
},
|
},
|
||||||
|
singleOption: {
|
||||||
/**
|
|
||||||
* When setting this to true, on initial render, no option will be selected.
|
|
||||||
* It it advisable to override `_noSelectionTemplate` method in the select-invoker
|
|
||||||
* to render some kind of placeholder initially
|
|
||||||
*/
|
|
||||||
hasNoDefaultSelected: {
|
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
reflect: true,
|
reflect: true,
|
||||||
attribute: 'has-no-default-selected',
|
attribute: 'single-option',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles() {
|
|
||||||
return [
|
|
||||||
css`
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host([hidden]) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host([disabled]) {
|
|
||||||
color: #adadad;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
get slots() {
|
get slots() {
|
||||||
return {
|
return {
|
||||||
...super.slots,
|
...super.slots,
|
||||||
invoker: () =>
|
invoker: () => document.createElement(LionSelectRich.getScopedTagName('lion-select-invoker')),
|
||||||
document.createElement(this.constructor.getScopedTagName('lion-select-invoker')),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {LionSelectInvoker} */
|
||||||
get _invokerNode() {
|
get _invokerNode() {
|
||||||
return Array.from(this.children).find(child => child.slot === 'invoker');
|
return /** @type {LionSelectInvoker} */ (Array.from(this.children).find(
|
||||||
}
|
child => child.slot === 'invoker',
|
||||||
|
));
|
||||||
get _listboxNode() {
|
|
||||||
return (
|
|
||||||
(this._overlayCtrl && this._overlayCtrl.contentNode) ||
|
|
||||||
Array.from(this.children).find(child => child.slot === 'input')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get _listboxActiveDescendantNode() {
|
|
||||||
return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
get serializedValue() {
|
|
||||||
return this.modelValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
|
|
||||||
// If you override one, gotta override the other, they go in pairs.
|
|
||||||
set serializedValue(value) {
|
|
||||||
super.serializedValue = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get checkedIndex() {
|
|
||||||
let checkedIndex = -1;
|
|
||||||
this.formElements.forEach((option, i) => {
|
|
||||||
if (option.checked) {
|
|
||||||
checkedIndex = i;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return checkedIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
set checkedIndex(index) {
|
|
||||||
if (this._listboxNode.children[index]) {
|
|
||||||
this._listboxNode.children[index].checked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeIndex() {
|
|
||||||
return this.formElements.findIndex(el => el.active === true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get _scrollTargetNode() {
|
get _scrollTargetNode() {
|
||||||
|
// @ts-expect-error _scrollTargetNode not on type
|
||||||
return this._overlayContentNode._scrollTargetNode || this._overlayContentNode;
|
return this._overlayContentNode._scrollTargetNode || this._overlayContentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
set activeIndex(index) {
|
get checkedIndex() {
|
||||||
if (this.formElements[index]) {
|
return /** @type {number} */ (super.checkedIndex);
|
||||||
const el = this.formElements[index];
|
}
|
||||||
el.active = true;
|
|
||||||
|
|
||||||
if (!isInView(this._scrollTargetNode, el)) {
|
set checkedIndex(i) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
super.checkedIndex = i;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.interactionMode = 'auto';
|
|
||||||
this.disabled = false;
|
|
||||||
// for interaction states
|
|
||||||
this._listboxActiveDescendant = null;
|
|
||||||
this.__hasInitialSelectedFormElement = false;
|
|
||||||
this.hasNoDefaultSelected = false;
|
|
||||||
this._repropagationRole = 'choice-group'; // configures FormControlMixin
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When invoker has focus, up and down arrow keys changes active state of listbox,
|
||||||
|
* without opening overlay.
|
||||||
|
* @type {Boolean}
|
||||||
|
*/
|
||||||
|
this.navigateWithinInvoker = false;
|
||||||
|
/**
|
||||||
|
* Aligns behavior for 'selectionFollowFocus' and 'navigateWithinInvoker' with
|
||||||
|
* platform. When 'auto' (default), platform is automatically detected
|
||||||
|
* @type {'windows/linux'|'mac'|'auto'}
|
||||||
|
*/
|
||||||
|
this.interactionMode = 'auto';
|
||||||
|
|
||||||
|
this.singleOption = false;
|
||||||
|
|
||||||
|
this.__onKeyUp = this.__onKeyUp.bind(this);
|
||||||
|
this.__invokerOnBlur = this.__invokerOnBlur.bind(this);
|
||||||
|
this.__overlayOnHide = this.__overlayOnHide.bind(this);
|
||||||
|
this.__overlayOnShow = this.__overlayOnShow.bind(this);
|
||||||
|
this.__invokerOnClick = this.__invokerOnClick.bind(this);
|
||||||
|
this.__overlayBeforeShow = this.__overlayBeforeShow.bind(this);
|
||||||
this.__focusInvokerOnLabelClick = this.__focusInvokerOnLabelClick.bind(this);
|
this.__focusInvokerOnLabelClick = this.__focusInvokerOnLabelClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// need to do this before anything else
|
super.connectedCallback();
|
||||||
this._listboxNode.registrationTarget = this;
|
|
||||||
if (super.connectedCallback) {
|
|
||||||
super.connectedCallback();
|
|
||||||
}
|
|
||||||
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
|
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
|
||||||
this.__setupInvokerNode();
|
this.__setupInvokerNode();
|
||||||
this.__setupListboxNode();
|
|
||||||
this.__setupEventListeners();
|
|
||||||
|
|
||||||
this.__toggleInvokerDisabled();
|
this.__toggleInvokerDisabled();
|
||||||
|
|
||||||
if (this._labelNode) {
|
if (this._labelNode) {
|
||||||
this._labelNode.addEventListener('click', this.__focusInvokerOnLabelClick);
|
this._labelNode.addEventListener('click', this.__focusInvokerOnLabelClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.registrationComplete.then(() => {
|
this.addEventListener('keyup', this.__onKeyUp);
|
||||||
this.__initInteractionStates();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (super.disconnectedCallback) {
|
super.disconnectedCallback();
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
if (this._labelNode) {
|
if (this._labelNode) {
|
||||||
this._labelNode.removeEventListener('click', this.__toggleChecked);
|
this._labelNode.removeEventListener('click', this.__focusInvokerOnLabelClick);
|
||||||
}
|
}
|
||||||
this._scrollTargetNode.removeEventListener('keydown', this.__overlayOnHide);
|
|
||||||
this.__teardownInvokerNode();
|
this.__teardownInvokerNode();
|
||||||
this.__teardownListboxNode();
|
this.removeEventListener('keyup', this.__onKeyUp);
|
||||||
this.__teardownEventListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {unknown} oldValue
|
||||||
|
*/
|
||||||
requestUpdateInternal(name, oldValue) {
|
requestUpdateInternal(name, oldValue) {
|
||||||
super.requestUpdateInternal(name, oldValue);
|
super.requestUpdateInternal(name, oldValue);
|
||||||
if (name === 'interactionMode') {
|
if (name === 'interactionMode') {
|
||||||
if (this.interactionMode === 'auto') {
|
if (this.interactionMode === 'auto') {
|
||||||
this.interactionMode = detectInteractionMode();
|
this.interactionMode = detectInteractionMode();
|
||||||
|
} else {
|
||||||
|
this.selectionFollowsFocus = Boolean(this.interactionMode === 'windows/linux');
|
||||||
|
this.navigateWithinInvoker = Boolean(this.interactionMode === 'windows/linux');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,35 +158,21 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
this.initInteractionState();
|
this.initInteractionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
get _inputNode() {
|
/**
|
||||||
// In FormControl, we get direct child [slot="input"]. This doesn't work, because the overlay
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
// system wraps it in [slot="_overlay-shadow-outlet"]
|
*/
|
||||||
return this.querySelector('[slot="input"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
${this._labelTemplate()} ${this._helpTextTemplate()} ${this._inputGroupTemplate()}
|
|
||||||
${this._feedbackTemplate()}
|
|
||||||
<slot name="_overlay-shadow-outlet"></slot>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updated(changedProperties) {
|
updated(changedProperties) {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
||||||
if (this.formElements.length === 1) {
|
if (this.formElements.length === 1) {
|
||||||
this.singleOption = true;
|
|
||||||
this._invokerNode.singleOption = true;
|
this._invokerNode.singleOption = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProperties.has('disabled')) {
|
if (changedProperties.has('disabled')) {
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
this._invokerNode.makeRequestToBeDisabled();
|
this._invokerNode.makeRequestToBeDisabled();
|
||||||
this.__requestOptionsToBeDisabled();
|
|
||||||
} else {
|
} else {
|
||||||
this._invokerNode.retractRequestToBeDisabled();
|
this._invokerNode.retractRequestToBeDisabled();
|
||||||
this.__retractRequestOptionsToBeDisabled();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,13 +187,13 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
if (changedProperties.has('_ariaDescribedNodes')) {
|
if (changedProperties.has('_ariaDescribedNodes')) {
|
||||||
this._invokerNode.setAttribute(
|
this._invokerNode.setAttribute(
|
||||||
'aria-describedby',
|
'aria-describedby',
|
||||||
this._inputNode.getAttribute('aria-describedby'),
|
/** @type {string} */ (this._inputNode.getAttribute('aria-describedby')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProperties.has('showsFeedbackFor')) {
|
if (changedProperties.has('showsFeedbackFor')) {
|
||||||
// The ValidateMixin sets aria-invalid on the inputNode, but in this component we also need it on the invoker
|
// The ValidateMixin sets aria-invalid on the inputNode, but in this component we also need it on the invoker
|
||||||
this._invokerNode.setAttribute('aria-invalid', this._hasFeedbackVisibleFor('error'));
|
this._invokerNode.setAttribute('aria-invalid', `${this._hasFeedbackVisibleFor('error')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,6 +202,7 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated. use _overlayCtrl.toggle */
|
||||||
toggle() {
|
toggle() {
|
||||||
this.opened = !this.opened;
|
this.opened = !this.opened;
|
||||||
}
|
}
|
||||||
|
|
@ -321,7 +210,7 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
/**
|
/**
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line class-methods-use-this
|
||||||
_inputGroupInputTemplate() {
|
_inputGroupInputTemplate() {
|
||||||
return html`
|
return html`
|
||||||
<div class="input-group__input">
|
<div class="input-group__input">
|
||||||
|
|
@ -334,66 +223,6 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Overrides FormRegistrar adding to make sure children have specific default states when added
|
|
||||||
*
|
|
||||||
* @override
|
|
||||||
* @param {*} child
|
|
||||||
* @param {Number} indexToInsertAt
|
|
||||||
*/
|
|
||||||
addFormElement(child, indexToInsertAt) {
|
|
||||||
super.addFormElement(child, indexToInsertAt);
|
|
||||||
|
|
||||||
// we need to adjust the elements being registered
|
|
||||||
/* eslint-disable no-param-reassign */
|
|
||||||
child.id = child.id || `${this.localName}-option-${uuid()}`;
|
|
||||||
|
|
||||||
if (this.disabled) {
|
|
||||||
child.makeRequestToBeDisabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
// the first elements checked by default
|
|
||||||
if (
|
|
||||||
!this.hasNoDefaultSelected &&
|
|
||||||
!this.__hasInitialSelectedFormElement &&
|
|
||||||
(!child.disabled || this.disabled)
|
|
||||||
) {
|
|
||||||
child.active = true;
|
|
||||||
child.checked = true;
|
|
||||||
this.__hasInitialSelectedFormElement = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: small perf improvement could be made if logic below would be scheduled to next update,
|
|
||||||
// so it occurs once for all options
|
|
||||||
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
|
|
||||||
this.formElements.forEach((el, idx) => {
|
|
||||||
el.setAttribute('aria-posinset', idx + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.__proxyChildModelValueChanged({ target: child });
|
|
||||||
this.resetInteractionState();
|
|
||||||
/* eslint-enable no-param-reassign */
|
|
||||||
}
|
|
||||||
|
|
||||||
__setupEventListeners() {
|
|
||||||
this.__onChildActiveChanged = this.__onChildActiveChanged.bind(this);
|
|
||||||
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
|
|
||||||
this.__onKeyUp = this.__onKeyUp.bind(this);
|
|
||||||
|
|
||||||
this._listboxNode.addEventListener('active-changed', this.__onChildActiveChanged);
|
|
||||||
this._listboxNode.addEventListener('model-value-changed', this.__proxyChildModelValueChanged);
|
|
||||||
this.addEventListener('keyup', this.__onKeyUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
__teardownEventListeners() {
|
|
||||||
this._listboxNode.removeEventListener('active-changed', this.__onChildActiveChanged);
|
|
||||||
this._listboxNode.removeEventListener(
|
|
||||||
'model-value-changed',
|
|
||||||
this.__proxyChildModelValueChanged,
|
|
||||||
);
|
|
||||||
this._listboxNode.removeEventListener('keyup', this.__onKeyUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
__toggleInvokerDisabled() {
|
__toggleInvokerDisabled() {
|
||||||
if (this._invokerNode) {
|
if (this._invokerNode) {
|
||||||
this._invokerNode.disabled = this.disabled;
|
this._invokerNode.disabled = this.disabled;
|
||||||
|
|
@ -401,34 +230,6 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__onChildActiveChanged({ target }) {
|
|
||||||
if (target.active === true) {
|
|
||||||
this.formElements.forEach(formElement => {
|
|
||||||
if (formElement !== target) {
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
formElement.active = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._listboxNode.setAttribute('aria-activedescendant', target.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__setAttributeForAllFormElements(attribute, value) {
|
|
||||||
this.formElements.forEach(formElement => {
|
|
||||||
formElement.setAttribute(attribute, value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
__proxyChildModelValueChanged(ev) {
|
|
||||||
// We need to redispatch the model-value-changed event on 'this', so it will
|
|
||||||
// align with FormControl.__repropagateChildrenValues method. Also, this makes
|
|
||||||
// it act like a portal, in case the listbox is put in a modal overlay on body level.
|
|
||||||
if (ev.stopPropagation) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
this.dispatchEvent(new CustomEvent('model-value-changed', { detail: { element: ev.target } }));
|
|
||||||
}
|
|
||||||
|
|
||||||
__syncInvokerElement() {
|
__syncInvokerElement() {
|
||||||
// sync to invoker
|
// sync to invoker
|
||||||
if (this._invokerNode) {
|
if (this._invokerNode) {
|
||||||
|
|
@ -436,141 +237,6 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__getNextEnabledOption(currentIndex, offset = 1) {
|
|
||||||
for (let i = currentIndex + offset; i < this.formElements.length; i += 1) {
|
|
||||||
if (this.formElements[i] && !this.formElements[i].disabled) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
__getPreviousEnabledOption(currentIndex, offset = -1) {
|
|
||||||
for (let i = currentIndex + offset; i >= 0; i -= 1) {
|
|
||||||
if (this.formElements[i] && !this.formElements[i].disabled) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc
|
|
||||||
* Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
|
|
||||||
* an item.
|
|
||||||
*
|
|
||||||
* @param ev - the keydown event object
|
|
||||||
*/
|
|
||||||
__listboxOnKeyUp(ev) {
|
|
||||||
if (this.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { key } = ev;
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case 'Escape':
|
|
||||||
ev.preventDefault();
|
|
||||||
this.opened = false;
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
case ' ':
|
|
||||||
ev.preventDefault();
|
|
||||||
if (this.interactionMode === 'mac') {
|
|
||||||
this.checkedIndex = this.activeIndex;
|
|
||||||
}
|
|
||||||
this.opened = false;
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
ev.preventDefault();
|
|
||||||
this.activeIndex = this.__getPreviousEnabledOption(this.activeIndex);
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
ev.preventDefault();
|
|
||||||
this.activeIndex = this.__getNextEnabledOption(this.activeIndex);
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
ev.preventDefault();
|
|
||||||
this.activeIndex = this.__getNextEnabledOption(0, 0);
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
ev.preventDefault();
|
|
||||||
this.activeIndex = this.__getPreviousEnabledOption(this.formElements.length - 1, 0);
|
|
||||||
break;
|
|
||||||
/* no default */
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End'];
|
|
||||||
if (keys.includes(key) && this.interactionMode === 'windows/linux') {
|
|
||||||
this.checkedIndex = this.activeIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__listboxOnKeyDown(ev) {
|
|
||||||
if (this.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { key } = ev;
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case 'Tab':
|
|
||||||
// Tab can only be caught in keydown
|
|
||||||
ev.preventDefault();
|
|
||||||
this.opened = false;
|
|
||||||
break;
|
|
||||||
/* no default */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__onKeyUp(ev) {
|
|
||||||
if (this.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.opened) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { key } = ev;
|
|
||||||
switch (key) {
|
|
||||||
case 'ArrowUp':
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
if (this.interactionMode === 'mac') {
|
|
||||||
this.opened = true;
|
|
||||||
} else {
|
|
||||||
this.checkedIndex = this.__getPreviousEnabledOption(this.checkedIndex);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
ev.preventDefault();
|
|
||||||
if (this.interactionMode === 'mac') {
|
|
||||||
this.opened = true;
|
|
||||||
} else {
|
|
||||||
this.checkedIndex = this.__getNextEnabledOption(this.checkedIndex);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
/* no default */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__requestOptionsToBeDisabled() {
|
|
||||||
this.formElements.forEach(el => {
|
|
||||||
if (el.makeRequestToBeDisabled) {
|
|
||||||
el.makeRequestToBeDisabled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
__retractRequestOptionsToBeDisabled() {
|
|
||||||
this.formElements.forEach(el => {
|
|
||||||
if (el.retractRequestToBeDisabled) {
|
|
||||||
el.retractRequestToBeDisabled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
__setupInvokerNode() {
|
__setupInvokerNode() {
|
||||||
this._invokerNode.id = `invoker-${this._inputId}`;
|
this._invokerNode.id = `invoker-${this._inputId}`;
|
||||||
this._invokerNode.setAttribute('aria-haspopup', 'listbox');
|
this._invokerNode.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
|
@ -578,17 +244,19 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
this.__setupInvokerNodeEventListener();
|
this.__setupInvokerNodeEventListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
__invokerOnClick() {
|
||||||
|
if (!this.disabled && !this.readOnly && !this.singleOption && !this.__blockListShow) {
|
||||||
|
this._overlayCtrl.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__invokerOnBlur() {
|
||||||
|
this.dispatchEvent(new Event('blur'));
|
||||||
|
}
|
||||||
|
|
||||||
__setupInvokerNodeEventListener() {
|
__setupInvokerNodeEventListener() {
|
||||||
this.__invokerOnClick = () => {
|
|
||||||
if (!this.disabled && !this.readOnly && !this.singleOption) {
|
|
||||||
this._overlayCtrl.toggle();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this._invokerNode.addEventListener('click', this.__invokerOnClick);
|
this._invokerNode.addEventListener('click', this.__invokerOnClick);
|
||||||
|
|
||||||
this.__invokerOnBlur = () => {
|
|
||||||
this.dispatchEvent(new Event('blur'));
|
|
||||||
};
|
|
||||||
this._invokerNode.addEventListener('blur', this.__invokerOnBlur);
|
this._invokerNode.addEventListener('blur', this.__invokerOnBlur);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -598,46 +266,8 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For ShadyDom the listboxNode is available right from the start so we can add those events
|
* @override OverlayMixin
|
||||||
* immediately.
|
|
||||||
* For native ShadowDom the select gets render before the listboxNode is available so we
|
|
||||||
* will add an event to the slotchange and add the events once available.
|
|
||||||
*/
|
*/
|
||||||
__setupListboxNode() {
|
|
||||||
if (this._listboxNode) {
|
|
||||||
this.__setupListboxNodeEventListener();
|
|
||||||
} else {
|
|
||||||
const inputSlot = this.shadowRoot.querySelector('slot[name=input]');
|
|
||||||
if (inputSlot) {
|
|
||||||
inputSlot.addEventListener('slotchange', () => {
|
|
||||||
this.__setupListboxNodeEventListener();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__setupListboxNodeEventListener() {
|
|
||||||
this.__listboxOnClick = () => {
|
|
||||||
this.opened = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
this._listboxNode.addEventListener('click', this.__listboxOnClick);
|
|
||||||
|
|
||||||
this.__listboxOnKeyUp = this.__listboxOnKeyUp.bind(this);
|
|
||||||
this._listboxNode.addEventListener('keyup', this.__listboxOnKeyUp);
|
|
||||||
|
|
||||||
this.__listboxOnKeyDown = this.__listboxOnKeyDown.bind(this);
|
|
||||||
this._listboxNode.addEventListener('keydown', this.__listboxOnKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
__teardownListboxNode() {
|
|
||||||
if (this._listboxNode) {
|
|
||||||
this._listboxNode.removeEventListener('click', this.__listboxOnClick);
|
|
||||||
this._listboxNode.removeEventListener('keyup', this.__listboxOnKeyUp);
|
|
||||||
this._listboxNode.removeEventListener('keydown', this.__listboxOnKeyDown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
_defineOverlayConfig() {
|
_defineOverlayConfig() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -653,36 +283,39 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
*/
|
*/
|
||||||
_noDefaultSelectedInheritsWidth() {
|
_noDefaultSelectedInheritsWidth() {
|
||||||
if (this.checkedIndex === -1) {
|
if (this.checkedIndex === -1) {
|
||||||
this._overlayCtrl.inheritsReferenceWidth = 'min';
|
this._overlayCtrl.updateConfig({ inheritsReferenceWidth: 'min' });
|
||||||
} else {
|
} else {
|
||||||
this._overlayCtrl.inheritsReferenceWidth = this._initialInheritsReferenceWidth;
|
this._overlayCtrl.updateConfig({
|
||||||
|
inheritsReferenceWidth: this._initialInheritsReferenceWidth,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
__overlayBeforeShow() {
|
||||||
|
if (this.hasNoDefaultSelected) {
|
||||||
|
this._noDefaultSelectedInheritsWidth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__overlayOnShow() {
|
||||||
|
if (this.checkedIndex != null) {
|
||||||
|
this.activeIndex = this.checkedIndex;
|
||||||
|
}
|
||||||
|
this._listboxNode.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
__overlayOnHide() {
|
||||||
|
this._invokerNode.focus();
|
||||||
|
}
|
||||||
|
|
||||||
_setupOverlayCtrl() {
|
_setupOverlayCtrl() {
|
||||||
super._setupOverlayCtrl();
|
super._setupOverlayCtrl();
|
||||||
this._initialInheritsReferenceWidth = this._overlayCtrl.inheritsReferenceWidth;
|
this._initialInheritsReferenceWidth = this._overlayCtrl.inheritsReferenceWidth;
|
||||||
this.__overlayBeforeShow = () => {
|
|
||||||
if (this.hasNoDefaultSelected) {
|
|
||||||
this._noDefaultSelectedInheritsWidth();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.__overlayOnShow = () => {
|
|
||||||
if (this.checkedIndex != null) {
|
|
||||||
this.activeIndex = this.checkedIndex;
|
|
||||||
}
|
|
||||||
this._listboxNode.focus();
|
|
||||||
};
|
|
||||||
this._overlayCtrl.addEventListener('before-show', this.__overlayBeforeShow);
|
this._overlayCtrl.addEventListener('before-show', this.__overlayBeforeShow);
|
||||||
this._overlayCtrl.addEventListener('show', this.__overlayOnShow);
|
this._overlayCtrl.addEventListener('show', this.__overlayOnShow);
|
||||||
|
|
||||||
this.__overlayOnHide = () => {
|
|
||||||
this._invokerNode.focus();
|
|
||||||
};
|
|
||||||
this._overlayCtrl.addEventListener('hide', this.__overlayOnHide);
|
this._overlayCtrl.addEventListener('hide', this.__overlayOnHide);
|
||||||
|
|
||||||
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
|
|
||||||
this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_teardownOverlayCtrl() {
|
_teardownOverlayCtrl() {
|
||||||
|
|
@ -692,21 +325,6 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
|
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
|
||||||
}
|
}
|
||||||
|
|
||||||
__preventScrollingWithArrowKeys(ev) {
|
|
||||||
if (this.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { key } = ev;
|
|
||||||
switch (key) {
|
|
||||||
case 'ArrowUp':
|
|
||||||
case 'ArrowDown':
|
|
||||||
case 'Home':
|
|
||||||
case 'End':
|
|
||||||
ev.preventDefault();
|
|
||||||
/* no default */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__focusInvokerOnLabelClick() {
|
__focusInvokerOnLabelClick() {
|
||||||
this._invokerNode.focus();
|
this._invokerNode.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -725,14 +343,103 @@ export class LionSelectRich extends ScopedElementsMixin(
|
||||||
return this._listboxNode;
|
return this._listboxNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
set fieldName(value) {
|
/**
|
||||||
this.__fieldName = value;
|
* @param {KeyboardEvent} ev
|
||||||
|
*/
|
||||||
|
__onKeyUp(ev) {
|
||||||
|
if (this.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.opened) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key } = ev;
|
||||||
|
switch (key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (this.navigateWithinInvoker) {
|
||||||
|
this.setCheckedIndex(this._getPreviousEnabledOption(this.checkedIndex));
|
||||||
|
} else {
|
||||||
|
this.opened = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
ev.preventDefault();
|
||||||
|
if (this.navigateWithinInvoker) {
|
||||||
|
this.setCheckedIndex(this._getNextEnabledOption(this.checkedIndex));
|
||||||
|
} else {
|
||||||
|
this.opened = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
/* no default */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get fieldName() {
|
/**
|
||||||
const label =
|
* @desc
|
||||||
this.label ||
|
* Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
|
||||||
(this.querySelector('[slot=label]') && this.querySelector('[slot=label]').textContent);
|
* an item.
|
||||||
return this.__fieldName || label || this.name;
|
*
|
||||||
|
* @param {KeyboardEvent} ev - the keydown event object
|
||||||
|
*/
|
||||||
|
_listboxOnKeyDown(ev) {
|
||||||
|
super._listboxOnKeyDown(ev);
|
||||||
|
|
||||||
|
if (this.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { key } = ev;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'Tab':
|
||||||
|
// Tab can only be caught in keydown
|
||||||
|
this.opened = false;
|
||||||
|
break;
|
||||||
|
/* no default */
|
||||||
|
case 'Escape':
|
||||||
|
this.opened = false;
|
||||||
|
this.__blockListShowDuringTransition();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
this.opened = false;
|
||||||
|
this.__blockListShowDuringTransition();
|
||||||
|
break;
|
||||||
|
/* no default */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_listboxOnClick = () => {
|
||||||
|
this.opened = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
_setupListboxNodeInteractions() {
|
||||||
|
super._setupListboxNodeInteractions();
|
||||||
|
this._listboxNode.addEventListener('click', this._listboxOnClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
_teardownListboxNode() {
|
||||||
|
super._teardownListboxNode();
|
||||||
|
if (this._listboxNode) {
|
||||||
|
this._listboxNode.removeEventListener('click', this._listboxOnClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normally, when textbox gets focus or a char is typed, it opens listbox.
|
||||||
|
* In transition phases (like clicking option) we prevent this.
|
||||||
|
*/
|
||||||
|
__blockListShowDuringTransition() {
|
||||||
|
this.__blockListShow = true;
|
||||||
|
// We need this timeout to make sure click handler triggered by keyup (space/enter) of
|
||||||
|
// button has been executed.
|
||||||
|
// TODO: alternative would be to let the 'checking' party 'release' this boolean
|
||||||
|
// Or: call 'stopPropagation' on keyup of keys that have been handled in keydown
|
||||||
|
setTimeout(() => {
|
||||||
|
this.__blockListShow = false;
|
||||||
|
}, 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { OverlayMixin } from '@lion/overlays';
|
||||||
import { LitElement } from 'lit-element';
|
import { LitElement } from 'lit-element';
|
||||||
import { defineCE, fixture, html, expect, unsafeStatic } from '@open-wc/testing';
|
import { defineCE, fixture, html, expect, unsafeStatic } from '@open-wc/testing';
|
||||||
|
import '@lion/listbox/lion-option.js';
|
||||||
|
import '@lion/listbox/lion-options.js';
|
||||||
import '../lion-select-rich.js';
|
import '../lion-select-rich.js';
|
||||||
import '../lion-options.js';
|
|
||||||
import '../lion-option.js';
|
|
||||||
|
|
||||||
const tagString = defineCE(
|
const tagString = defineCE(
|
||||||
class extends OverlayMixin(LitElement) {
|
class extends OverlayMixin(LitElement) {
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,13 @@
|
||||||
import { Required } from '@lion/form-core';
|
import { Required } from '@lion/form-core';
|
||||||
import { expect, html, triggerBlurFor, triggerFocusFor, fixture } from '@open-wc/testing';
|
import { expect, html, triggerBlurFor, triggerFocusFor, fixture } from '@open-wc/testing';
|
||||||
|
|
||||||
import '../lion-option.js';
|
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
||||||
import '../lion-options.js';
|
import '@lion/listbox/lion-option.js';
|
||||||
|
import '@lion/listbox/lion-options.js';
|
||||||
import '../lion-select-rich.js';
|
import '../lion-select-rich.js';
|
||||||
import './keyboardEventShimIE.js';
|
|
||||||
|
|
||||||
describe('lion-select-rich interactions', () => {
|
describe('lion-select-rich interactions', () => {
|
||||||
describe('values', () => {
|
|
||||||
it('registers options', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.formElements.length).to.equal(2);
|
|
||||||
expect(el.formElements).to.eql([
|
|
||||||
el.querySelectorAll('lion-option')[0],
|
|
||||||
el.querySelectorAll('lion-option')[1],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has the first element by default checked and active', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.querySelector('lion-option').checked).to.be.true;
|
|
||||||
expect(el.querySelector('lion-option').active).to.be.true;
|
|
||||||
expect(el.modelValue).to.equal(10);
|
|
||||||
|
|
||||||
expect(el.checkedIndex).to.equal(0);
|
|
||||||
expect(el.activeIndex).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows null choiceValue', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${null}>Please select value</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.modelValue).to.be.null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has the checked option as modelValue', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.modelValue).to.equal(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has an activeIndex', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.activeIndex).to.equal(0);
|
|
||||||
|
|
||||||
el.querySelectorAll('lion-option')[1].active = true;
|
|
||||||
expect(el.querySelectorAll('lion-option')[0].active).to.be.false;
|
|
||||||
expect(el.activeIndex).to.equal(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Keyboard navigation', () => {
|
describe('Keyboard navigation', () => {
|
||||||
it('does not allow to navigate above the first or below the last option', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(() => {
|
|
||||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
|
|
||||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
|
||||||
}).to.not.throw();
|
|
||||||
expect(el.checkedIndex).to.equal(0);
|
|
||||||
expect(el.activeIndex).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('navigates to first and last option with [Home] and [End] keys', async () => {
|
it('navigates to first and last option with [Home] and [End] keys', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich opened interaction-mode="windows/linux">
|
<lion-select-rich opened interaction-mode="windows/linux">
|
||||||
|
|
@ -113,44 +21,12 @@ describe('lion-select-rich interactions', () => {
|
||||||
`);
|
`);
|
||||||
expect(el.modelValue).to.equal(30);
|
expect(el.modelValue).to.equal(30);
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
|
||||||
expect(el.modelValue).to.equal(10);
|
expect(el.modelValue).to.equal(10);
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'End' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
|
||||||
expect(el.modelValue).to.equal(40);
|
expect(el.modelValue).to.equal(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: nice to have
|
|
||||||
it.skip('selects a value with single [character] key', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input" name="foo">
|
|
||||||
<lion-option .choiceValue=${'a'}>A</lion-option>
|
|
||||||
<lion-option .choiceValue=${'b'}>B</lion-option>
|
|
||||||
<lion-option .choiceValue=${'c'}>C</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.choiceValue).to.equal('a');
|
|
||||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'C' }));
|
|
||||||
expect(el.choiceValue).to.equal('c');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('selects a value with multiple [character] keys', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input" name="foo">
|
|
||||||
<lion-option .choiceValue=${'bar'}>Bar</lion-option>
|
|
||||||
<lion-option .choiceValue=${'far'}>Far</lion-option>
|
|
||||||
<lion-option .choiceValue=${'foo'}>Foo</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'F' }));
|
|
||||||
expect(el.choiceValue).to.equal('far');
|
|
||||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'O' }));
|
|
||||||
expect(el.choiceValue).to.equal('foo');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Keyboard navigation Windows', () => {
|
describe('Keyboard navigation Windows', () => {
|
||||||
|
|
@ -180,12 +56,12 @@ describe('lion-select-rich interactions', () => {
|
||||||
expect(el.checkedIndex).to.equal(0);
|
expect(el.checkedIndex).to.equal(0);
|
||||||
expectOnlyGivenOneOptionToBeChecked(options, 0);
|
expectOnlyGivenOneOptionToBeChecked(options, 0);
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
||||||
expect(el.activeIndex).to.equal(1);
|
expect(el.activeIndex).to.equal(1);
|
||||||
expect(el.checkedIndex).to.equal(1);
|
expect(el.checkedIndex).to.equal(1);
|
||||||
expectOnlyGivenOneOptionToBeChecked(options, 1);
|
expectOnlyGivenOneOptionToBeChecked(options, 1);
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
|
||||||
expect(el.activeIndex).to.equal(0);
|
expect(el.activeIndex).to.equal(0);
|
||||||
expect(el.checkedIndex).to.equal(0);
|
expect(el.checkedIndex).to.equal(0);
|
||||||
expectOnlyGivenOneOptionToBeChecked(options, 0);
|
expectOnlyGivenOneOptionToBeChecked(options, 0);
|
||||||
|
|
@ -226,30 +102,6 @@ describe('lion-select-rich interactions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Keyboard navigation Mac', () => {
|
|
||||||
it('navigates through open list with [ArrowDown] [ArrowUp] keys activates the option', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich opened interaction-mode="mac">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
<lion-option .choiceValue=${30}>Item 3</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.activeIndex).to.equal(0);
|
|
||||||
expect(el.checkedIndex).to.equal(0);
|
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
|
||||||
expect(el.activeIndex).to.equal(1);
|
|
||||||
expect(el.checkedIndex).to.equal(0);
|
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
|
|
||||||
expect(el.activeIndex).to.equal(0);
|
|
||||||
expect(el.checkedIndex).to.equal(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Disabled', () => {
|
describe('Disabled', () => {
|
||||||
it('cannot be focused if disabled', async () => {
|
it('cannot be focused if disabled', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
|
|
@ -260,32 +112,6 @@ describe('lion-select-rich interactions', () => {
|
||||||
expect(el._invokerNode.tabIndex).to.equal(-1);
|
expect(el._invokerNode.tabIndex).to.equal(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still has a checked value', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich disabled>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.modelValue).to.equal(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cannot be navigated with keyboard if disabled', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich disabled>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
|
||||||
expect(el.modelValue).to.equal(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cannot be opened via click if disabled', async () => {
|
it('cannot be opened via click if disabled', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich disabled>
|
<lion-select-rich disabled>
|
||||||
|
|
@ -307,194 +133,9 @@ describe('lion-select-rich interactions', () => {
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(el._invokerNode.hasAttribute('disabled')).to.be.false;
|
expect(el._invokerNode.hasAttribute('disabled')).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips disabled options while navigating through list with [ArrowDown] [ArrowUp] keys', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20} disabled>Item 2</lion-option>
|
|
||||||
<lion-option .choiceValue=${30}>Item 3</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
|
||||||
expect(el.activeIndex).to.equal(2);
|
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
|
|
||||||
expect(el.activeIndex).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// flaky test
|
|
||||||
it.skip('skips disabled options while navigates to first and last option with [Home] and [End] keys', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input" name="foo">
|
|
||||||
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
<lion-option .choiceValue=${30} checked>Item 3</lion-option>
|
|
||||||
<lion-option .choiceValue=${40} disabled>Item 4</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.activeIndex).to.equal(2);
|
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'End' }));
|
|
||||||
expect(el.activeIndex).to.equal(2);
|
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' }));
|
|
||||||
expect(el.activeIndex).to.equal(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('checks the first enabled option', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
<lion-option .choiceValue=${30}>Item 3</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.activeIndex).to.equal(1);
|
|
||||||
expect(el.checkedIndex).to.equal(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sync its disabled state to all options', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const options = [...el.querySelectorAll('lion-option')];
|
|
||||||
el.disabled = true;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(options[0].disabled).to.be.true;
|
|
||||||
expect(options[1].disabled).to.be.true;
|
|
||||||
|
|
||||||
el.disabled = false;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(options[0].disabled).to.be.true;
|
|
||||||
expect(options[1].disabled).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can be enabled (incl. its options) even if it starts as disabled', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich disabled>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10} disabled>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const options = [...el.querySelectorAll('lion-option')];
|
|
||||||
expect(options[0].disabled).to.be.true;
|
|
||||||
expect(options[1].disabled).to.be.true;
|
|
||||||
|
|
||||||
el.disabled = false;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(options[0].disabled).to.be.true;
|
|
||||||
expect(options[1].disabled).to.be.false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Programmatic interaction', () => {
|
|
||||||
it('can set active state', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20} id="myId">Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const opt = el.querySelectorAll('lion-option')[1];
|
|
||||||
opt.active = true;
|
|
||||||
expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('myId');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can set checked state', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const option = el.querySelectorAll('lion-option')[1];
|
|
||||||
option.checked = true;
|
|
||||||
expect(el.modelValue).to.equal(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not allow to set checkedIndex or activeIndex to be out of bound', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(() => {
|
|
||||||
el.activeIndex = -1;
|
|
||||||
el.activeIndex = 1;
|
|
||||||
el.checkedIndex = -1;
|
|
||||||
el.checkedIndex = 1;
|
|
||||||
}).to.not.throw();
|
|
||||||
expect(el.checkedIndex).to.equal(0);
|
|
||||||
expect(el.activeIndex).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unsets checked on other options when option becomes checked', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const options = el.querySelectorAll('lion-option');
|
|
||||||
expect(options[0].checked).to.be.true;
|
|
||||||
options[1].checked = true;
|
|
||||||
expect(options[0].checked).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unsets active on other options when option becomes active', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const options = el.querySelectorAll('lion-option');
|
|
||||||
expect(options[0].active).to.be.true;
|
|
||||||
options[1].active = true;
|
|
||||||
expect(options[0].active).to.be.false;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Interaction states', () => {
|
describe('Interaction states', () => {
|
||||||
it('becomes dirty if value changed once', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.dirty).to.be.false;
|
|
||||||
el.modelValue = 20;
|
|
||||||
expect(el.dirty).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('becomes touched if blurred once', async () => {
|
it('becomes touched if blurred once', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich>
|
<lion-select-rich>
|
||||||
|
|
@ -509,114 +150,9 @@ describe('lion-select-rich interactions', () => {
|
||||||
await triggerBlurFor(el._invokerNode);
|
await triggerBlurFor(el._invokerNode);
|
||||||
expect(el.touched).to.be.true;
|
expect(el.touched).to.be.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is prefilled if there is a value on init', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.prefilled).to.be.true;
|
|
||||||
|
|
||||||
const elEmpty = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${null}>Please select a value</lion-option>
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(elEmpty.prefilled).to.be.false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Validation', () => {
|
|
||||||
it('can be required', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich .validators=${[new Required()]}>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${null}>Please select a value</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.hasFeedbackFor).to.include('error');
|
|
||||||
expect(el.validationStates).to.have.a.property('error');
|
|
||||||
expect(el.validationStates.error).to.have.a.property('Required');
|
|
||||||
|
|
||||||
el.modelValue = 20;
|
|
||||||
expect(el.hasFeedbackFor).not.to.include('error');
|
|
||||||
expect(el.validationStates).to.have.a.property('error');
|
|
||||||
expect(el.validationStates.error).not.to.have.a.property('Required');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
describe('Accessibility', () => {
|
||||||
it('creates unique ids for all children', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input" name="foo">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20} selected>Item 2</lion-option>
|
|
||||||
<lion-option .choiceValue=${30} id="predefined">Item 3</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.querySelectorAll('lion-option')[0].id).to.exist;
|
|
||||||
expect(el.querySelectorAll('lion-option')[1].id).to.exist;
|
|
||||||
expect(el.querySelectorAll('lion-option')[2].id).to.equal('predefined');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a reference to the selected option', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input" name="foo">
|
|
||||||
<lion-option .choiceValue=${10} id="first">Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20} checked id="second">Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('first');
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
|
||||||
expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('second');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input" name="foo">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
<lion-option .choiceValue=${30}>Item 3</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
|
|
||||||
optionEls.forEach(optionEl => {
|
|
||||||
expect(optionEl.getAttribute('aria-setsize')).to.equal('3');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
<lion-option .choiceValue=${30}>Item 3</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
|
|
||||||
optionEls.forEach((oEl, i) => {
|
|
||||||
expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets [aria-invalid="true"] to "._invokerNode" when there is an error', async () => {
|
it('sets [aria-invalid="true"] to "._invokerNode" when there is an error', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich .validators=${[new Required()]}>
|
<lion-select-rich .validators=${[new Required()]}>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { LitElement } from '@lion/core';
|
import { LitElement } from '@lion/core';
|
||||||
import { OverlayController } from '@lion/overlays';
|
import { OverlayController } from '@lion/overlays';
|
||||||
import { Required } from '@lion/form-core';
|
|
||||||
import {
|
import {
|
||||||
aTimeout,
|
aTimeout,
|
||||||
defineCE,
|
defineCE,
|
||||||
|
|
@ -11,111 +10,13 @@ import {
|
||||||
fixture,
|
fixture,
|
||||||
} from '@open-wc/testing';
|
} from '@open-wc/testing';
|
||||||
import { LionSelectInvoker, LionSelectRich } from '../index.js';
|
import { LionSelectInvoker, LionSelectRich } from '../index.js';
|
||||||
import '../lion-option.js';
|
|
||||||
import '../lion-options.js';
|
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
||||||
|
import '@lion/listbox/lion-option.js';
|
||||||
|
import '@lion/listbox/lion-options.js';
|
||||||
import '../lion-select-rich.js';
|
import '../lion-select-rich.js';
|
||||||
import './keyboardEventShimIE.js';
|
|
||||||
|
|
||||||
describe('lion-select-rich', () => {
|
describe('lion-select-rich', () => {
|
||||||
it('has a single modelValue representing the currently checked option', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich name="foo">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.modelValue).to.equal(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('automatically sets the name attribute of child checkboxes to its own name', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich name="foo">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.formElements[0].name).to.equal('foo');
|
|
||||||
expect(el.formElements[1].name).to.equal('foo');
|
|
||||||
|
|
||||||
const validChild = await fixture(html` <lion-option .choiceValue=${30}>Item 3</lion-option> `);
|
|
||||||
el.appendChild(validChild);
|
|
||||||
|
|
||||||
expect(el.formElements[2].name).to.equal('foo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich name="foo">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10} checked>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const invalidChild = await fixture(html` <lion-option .modelValue=${'Lara'}></lion-option> `);
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
el.addFormElement(invalidChild);
|
|
||||||
}).to.throw(
|
|
||||||
'The lion-select-rich name="foo" does not allow to register lion-option with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if a child element with a different name than the group tries to register', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich name="gender">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'female'} checked></lion-option>
|
|
||||||
<lion-option .choiceValue=${'other'}></lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const invalidChild = await fixture(html`
|
|
||||||
<lion-option name="foo" .choiceValue=${'male'}></lion-option>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
el.addFormElement(invalidChild);
|
|
||||||
}).to.throw(
|
|
||||||
'The lion-select-rich name="gender" does not allow to register lion-option with custom names (name="foo" given)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can set initial modelValue on creation', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich name="gender" .modelValue=${'other'}>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'male'}></lion-option>
|
|
||||||
<lion-option .choiceValue=${'female'}></lion-option>
|
|
||||||
<lion-option .choiceValue=${'other'}></lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.modelValue).to.equal('other');
|
|
||||||
expect(el.formElements[2].checked).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`has a fieldName based on the label`, async () => {
|
|
||||||
const el1 = await fixture(html`
|
|
||||||
<lion-select-rich label="foo"><lion-options slot="input"></lion-options></lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el1.fieldName).to.equal(el1._labelNode.textContent);
|
|
||||||
|
|
||||||
const el2 = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<label slot="label">bar</label><lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el2.fieldName).to.equal(el2._labelNode.textContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clicking the label should focus the invoker', async () => {
|
it('clicking the label should focus the invoker', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich label="foo">
|
<lion-select-rich label="foo">
|
||||||
|
|
@ -127,119 +28,6 @@ describe('lion-select-rich', () => {
|
||||||
expect(document.activeElement === el._invokerNode).to.be.true;
|
expect(document.activeElement === el._invokerNode).to.be.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`has a fieldName based on the name if no label exists`, async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich name="foo"><lion-options slot="input"></lion-options></lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.fieldName).to.equal(el.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`can override fieldName`, async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich label="foo" .fieldName="${'bar'}"
|
|
||||||
><lion-options slot="input"></lion-options
|
|
||||||
></lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.__fieldName).to.equal(el.fieldName);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not have a tabindex', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.hasAttribute('tabindex')).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates the name attribute to its children options', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich name="foo">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
const optOne = el.querySelectorAll('lion-option')[0];
|
|
||||||
const optTwo = el.querySelectorAll('lion-option')[1];
|
|
||||||
|
|
||||||
expect(optOne.name).to.equal('foo');
|
|
||||||
expect(optTwo.name).to.equal('foo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports validation', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich
|
|
||||||
id="color"
|
|
||||||
name="color"
|
|
||||||
label="Favorite color"
|
|
||||||
.validators="${[new Required()]}"
|
|
||||||
>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${null}>select a color</lion-option>
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
|
||||||
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
|
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.hasFeedbackFor.includes('error')).to.be.true;
|
|
||||||
expect(el.showsFeedbackFor.includes('error')).to.be.false;
|
|
||||||
|
|
||||||
// test submitted prop explicitly, since we dont extend field, we add the prop manually
|
|
||||||
el.submitted = true;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(el.showsFeedbackFor.includes('error')).to.be.true;
|
|
||||||
|
|
||||||
el._listboxNode.children[1].checked = true;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(el.hasFeedbackFor.includes('error')).to.be.false;
|
|
||||||
expect(el.showsFeedbackFor.includes('error')).to.be.false;
|
|
||||||
|
|
||||||
el._listboxNode.children[0].checked = true;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(el.hasFeedbackFor.includes('error')).to.be.true;
|
|
||||||
expect(el.showsFeedbackFor.includes('error')).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports having no default selection initially', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich id="color" name="color" label="Favorite color" has-no-default-selected>
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
|
||||||
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.selectedElement).to.be.undefined;
|
|
||||||
expect(el.modelValue).to.equal('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports changing the selection through serializedValue setter', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich id="color" name="color" label="Favorite color">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
|
||||||
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.checkedIndex).to.equal(0);
|
|
||||||
expect(el.serializedValue).to.equal('red');
|
|
||||||
|
|
||||||
el.serializedValue = 'hotpink';
|
|
||||||
|
|
||||||
expect(el.checkedIndex).to.equal(1);
|
|
||||||
expect(el.serializedValue).to.equal('hotpink');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Invoker', () => {
|
describe('Invoker', () => {
|
||||||
it('generates an lion-select-invoker if no invoker is provided', async () => {
|
it('generates an lion-select-invoker if no invoker is provided', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
|
|
@ -454,7 +242,7 @@ describe('lion-select-rich', () => {
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// The default is min, so we override that behavior here
|
// The default is min, so we override that behavior here
|
||||||
el._overlayCtrl.inheritsReferenceWidth = 'full';
|
el._overlayCtrl.updateConfig({ inheritsReferenceWidth: 'full' });
|
||||||
el._initialInheritsReferenceWidth = 'full';
|
el._initialInheritsReferenceWidth = 'full';
|
||||||
|
|
||||||
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full');
|
expect(el._overlayCtrl.inheritsReferenceWidth).to.equal('full');
|
||||||
|
|
@ -486,7 +274,7 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
elSingleoption._invokerNode.click();
|
elSingleoption._invokerNode.click();
|
||||||
await elSingleoption.updateComplete;
|
await elSingleoption.updateComplete;
|
||||||
expect(elSingleoption.singleOption).to.be.undefined;
|
expect(elSingleoption.singleOption).to.be.false;
|
||||||
|
|
||||||
const optionELm = elSingleoption.querySelectorAll('lion-option')[0];
|
const optionELm = elSingleoption.querySelectorAll('lion-option')[0];
|
||||||
optionELm.parentNode.removeChild(optionELm);
|
optionELm.parentNode.removeChild(optionELm);
|
||||||
|
|
@ -538,7 +326,7 @@ describe('lion-select-rich', () => {
|
||||||
<lion-options slot="input"></lion-options>
|
<lion-options slot="input"></lion-options>
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
expect(el.opened).to.be.false;
|
expect(el.opened).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -588,7 +376,7 @@ describe('lion-select-rich', () => {
|
||||||
<lion-options slot="input"></lion-options>
|
<lion-options slot="input"></lion-options>
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||||
expect(el.opened).to.be.false;
|
expect(el.opened).to.be.false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -608,7 +396,7 @@ describe('lion-select-rich', () => {
|
||||||
el.activeIndex = 1;
|
el.activeIndex = 1;
|
||||||
expect(el.checkedIndex).to.equal(0);
|
expect(el.checkedIndex).to.equal(0);
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||||
expect(el.opened).to.be.false;
|
expect(el.opened).to.be.false;
|
||||||
expect(el.checkedIndex).to.equal(1);
|
expect(el.checkedIndex).to.equal(1);
|
||||||
});
|
});
|
||||||
|
|
@ -667,71 +455,9 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('true');
|
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is accessible when closed', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich label="age">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
await expect(el).to.be.accessible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is accessible when opened', async () => {
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich label="age">
|
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el.opened = true;
|
|
||||||
await el.updateComplete;
|
|
||||||
await el.updateComplete; // need 2 awaits as overlay.show is an async function
|
|
||||||
|
|
||||||
await expect(el).to.be.accessible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Use cases', () => {
|
describe('Use cases', () => {
|
||||||
it('works for complex array data', async () => {
|
|
||||||
const objs = [
|
|
||||||
{ type: 'mastercard', label: 'Master Card', amount: 12000, active: true },
|
|
||||||
{ type: 'visacard', label: 'Visa Card', amount: 0, active: false },
|
|
||||||
];
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-select-rich label="Favorite color" name="color">
|
|
||||||
<lion-options slot="input">
|
|
||||||
${objs.map(
|
|
||||||
obj => html`
|
|
||||||
<lion-option .modelValue=${{ value: obj, checked: false }}
|
|
||||||
>${obj.label}</lion-option
|
|
||||||
>
|
|
||||||
`,
|
|
||||||
)}
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.modelValue).to.deep.equal({
|
|
||||||
type: 'mastercard',
|
|
||||||
label: 'Master Card',
|
|
||||||
amount: 12000,
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
el.checkedIndex = 1;
|
|
||||||
expect(el.modelValue).to.deep.equal({
|
|
||||||
type: 'visacard',
|
|
||||||
label: 'Visa Card',
|
|
||||||
amount: 0,
|
|
||||||
active: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps showing the selected item after a new item has been added in the selectedIndex position', async () => {
|
it('keeps showing the selected item after a new item has been added in the selectedIndex position', async () => {
|
||||||
const mySelectContainerTagString = defineCE(
|
const mySelectContainerTagString = defineCE(
|
||||||
class extends LitElement {
|
class extends LitElement {
|
||||||
|
|
@ -880,24 +606,4 @@ describe('lion-select-rich', () => {
|
||||||
expect(el.modelValue).to.equal('');
|
expect(el.modelValue).to.equal('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Instantiation methods', () => {
|
|
||||||
it('can be instantiated via "document.createElement"', async () => {
|
|
||||||
let properlyInstantiated = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const el = document.createElement('lion-select-rich');
|
|
||||||
const optionsEl = document.createElement('lion-options');
|
|
||||||
optionsEl.slot = 'input';
|
|
||||||
const optionEl = document.createElement('lion-option');
|
|
||||||
optionsEl.appendChild(optionEl);
|
|
||||||
el.appendChild(optionsEl);
|
|
||||||
properlyInstantiated = true;
|
|
||||||
} catch (e) {
|
|
||||||
throw Error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(properlyInstantiated).to.be.true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
"packages/form-core/**/*.js",
|
"packages/form-core/**/*.js",
|
||||||
"packages/overlays/**/*.js",
|
"packages/overlays/**/*.js",
|
||||||
"packages/tooltip/**/*.js",
|
"packages/tooltip/**/*.js",
|
||||||
"packages/button/src/**/*.js"
|
"packages/button/src/**/*.js",
|
||||||
|
"packages/listbox/src/*.js"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue