feat(combobox): new package combobox
Co-authored-by: Joren Broekema <Joren.Broekema@ing.com>
This commit is contained in:
parent
278798636c
commit
01a798e59e
43 changed files with 4724 additions and 1762 deletions
25
.changeset/new-tools-march.md
Normal file
25
.changeset/new-tools-march.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
'@lion/combobox': minor
|
||||||
|
'@lion/core': minor
|
||||||
|
'@lion/overlays': minor
|
||||||
|
'@lion/form-core': patch
|
||||||
|
'@lion/form-integrations': patch
|
||||||
|
'@lion/listbox': patch
|
||||||
|
'@lion/select-rich': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Combobox package
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- combobox: new combobox package
|
||||||
|
- core: expanded browsers detection utils
|
||||||
|
- core: closest() polyfill for IE
|
||||||
|
- overlays: allow OverlayMixin to specify reference node (when invokerNode should not be positioned against)
|
||||||
|
- form-core: add `_onLabelClick` to FormControlMixin. Subclassers should override this
|
||||||
|
|
||||||
|
## Patches
|
||||||
|
|
||||||
|
- form-core: make ChoiceGroupMixin a suite
|
||||||
|
- listbox: move generic tests from combobox to listbox
|
||||||
|
- select-rich: enhance tests
|
||||||
|
|
@ -11,17 +11,17 @@
|
||||||
z-index: unset;
|
z-index: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sbdocs.sbdocs-preview>div:first-child {
|
.sbdocs.sbdocs-preview > div:first-child {
|
||||||
z-index: 1;
|
z-index: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sbdocs.sbdocs-preview>div>div {
|
.sbdocs.sbdocs-preview > div > div {
|
||||||
overflow: initial;
|
overflow: initial;
|
||||||
z-index: unset;
|
z-index: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sbdocs.sbdocs-preview>div>div [scale='1'] {
|
.sbdocs.sbdocs-preview > div > div [scale='1'] {
|
||||||
z-index: 1;
|
z-index: unset;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ The accessibility column indicates whether the functionality is accessible in it
|
||||||
| Package | Version | Description | Accessibility |
|
| Package | Version | Description | Accessibility |
|
||||||
| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------- |
|
| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------- |
|
||||||
| **-- [Form System](https://lion-web-components.netlify.app/?path=/docs/forms-intro--page) --** | | A system that lets you make complex forms with ease, including: validation, translations. | ✔️ |
|
| **-- [Form System](https://lion-web-components.netlify.app/?path=/docs/forms-intro--page) --** | | A system that lets you make complex forms with ease, including: validation, translations. | ✔️ |
|
||||||
|
| [combobox](https://lion-web-components.netlify.app/?path=/docs/forms-combobox-overview--main) | [](https://www.npmjs.com/package/@lion/form) | Text box controlling popup listbox | ✔️ |
|
||||||
| [form](https://lion-web-components.netlify.app/?path=/docs/forms-form-overview--main) | [](https://www.npmjs.com/package/@lion/form) | Wrapper for multiple form elements | ✔️ |
|
| [form](https://lion-web-components.netlify.app/?path=/docs/forms-form-overview--main) | [](https://www.npmjs.com/package/@lion/form) | Wrapper for multiple form elements | ✔️ |
|
||||||
| [form-core](https://lion-web-components.netlify.app/?path=/docs/forms-system-overview--page) | [](https://www.npmjs.com/package/@lion/form-core) | Core functionality for all form controls | ✔️ |
|
| [form-core](https://lion-web-components.netlify.app/?path=/docs/forms-system-overview--page) | [](https://www.npmjs.com/package/@lion/form-core) | Core functionality for all form controls | ✔️ |
|
||||||
| [form-integrations](https://lion-web-components.netlify.app/?path=/docs/forms-features-overview--main) | [](https://www.npmjs.com/package/@lion/form-integrations) | Shows form elements in an integrated way | ✔️ |
|
| [form-integrations](https://lion-web-components.netlify.app/?path=/docs/forms-features-overview--main) | [](https://www.npmjs.com/package/@lion/form-integrations) | Shows form elements in an integrated way | ✔️ |
|
||||||
|
|
@ -55,6 +56,7 @@ The accessibility column indicates whether the functionality is accessible in it
|
||||||
| [input-iban](https://lion-web-components.netlify.app/?path=/docs/forms-input-iban--main) | [](https://www.npmjs.com/package/@lion/input-iban) | Input element for IBANs | ✔️ |
|
| [input-iban](https://lion-web-components.netlify.app/?path=/docs/forms-input-iban--main) | [](https://www.npmjs.com/package/@lion/input-iban) | Input element for IBANs | ✔️ |
|
||||||
| [input-range](https://lion-web-components.netlify.app/?path=/docs/forms-input-range--main) | [](https://www.npmjs.com/package/@lion/input-range) | Input element for a range of values | ✔️ |
|
| [input-range](https://lion-web-components.netlify.app/?path=/docs/forms-input-range--main) | [](https://www.npmjs.com/package/@lion/input-range) | Input element for a range of values | ✔️ |
|
||||||
| [input-stepper](https://lion-web-components.netlify.app/?path=/docs/forms-input-stepper--main) | [](https://www.npmjs.com/package/@lion/input-stepper) | Input stepper element for the predefined range | ✔️ |
|
| [input-stepper](https://lion-web-components.netlify.app/?path=/docs/forms-input-stepper--main) | [](https://www.npmjs.com/package/@lion/input-stepper) | Input stepper element for the predefined range | ✔️ |
|
||||||
|
| [listbox](https://lion-web-components.netlify.app/?path=/docs/forms-listbox-overview--main) | [](https://www.npmjs.com/package/@lion/form) | Interactive list with selectable options | ✔️ |
|
||||||
| [radio-group](https://lion-web-components.netlify.app/?path=/docs/forms-radio-group--main) | [](https://www.npmjs.com/package/@lion/radio-group) | Group of radios | ✔️ |
|
| [radio-group](https://lion-web-components.netlify.app/?path=/docs/forms-radio-group--main) | [](https://www.npmjs.com/package/@lion/radio-group) | Group of radios | ✔️ |
|
||||||
| [select](https://lion-web-components.netlify.app/?path=/docs/forms-select--main) | [](https://www.npmjs.com/package/@lion/select) | Simple native dropdown element | ✔️ |
|
| [select](https://lion-web-components.netlify.app/?path=/docs/forms-select--main) | [](https://www.npmjs.com/package/@lion/select) | Simple native dropdown element | ✔️ |
|
||||||
| [select-rich](https://lion-web-components.netlify.app/?path=/docs/forms-select-rich--main) | [](https://www.npmjs.com/package/@lion/select-rich) | 'rich' version of the native dropdown element | [#243][i243] |
|
| [select-rich](https://lion-web-components.netlify.app/?path=/docs/forms-select-rich--main) | [](https://www.npmjs.com/package/@lion/select-rich) | 'rich' version of the native dropdown element | [#243][i243] |
|
||||||
|
|
|
||||||
1
packages/combobox/CHANGELOG.md
Normal file
1
packages/combobox/CHANGELOG.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
204
packages/combobox/README.md
Normal file
204
packages/combobox/README.md
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
# Combobox
|
||||||
|
|
||||||
|
A combobox is a widget made up of the combination of two distinct elements:
|
||||||
|
|
||||||
|
- a single-line textbox
|
||||||
|
- an associated listbox overlay
|
||||||
|
|
||||||
|
Based on the combobox configuration and entered texbox value, options in the listbox will be
|
||||||
|
filtered, checked, focused and the textbox value may be autocompleted.
|
||||||
|
Optionally the combobox contains a graphical button adjacent to the textbox, indicating the
|
||||||
|
availability of the popup.
|
||||||
|
|
||||||
|
> Fore more information, consult [Combobox wai-aria design pattern](https://www.w3.org/TR/wai-aria-practices/#combobox)
|
||||||
|
|
||||||
|
```js script
|
||||||
|
import { html } from 'lit-html';
|
||||||
|
import { Required } from '@lion/form-core';
|
||||||
|
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
|
||||||
|
import { listboxData } from '@lion/listbox/docs/listboxData.js';
|
||||||
|
import '@lion/listbox/lion-option.js';
|
||||||
|
import './lion-combobox.js';
|
||||||
|
import './docs/lion-combobox-selection-display.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Forms/Combobox',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const main = () => html`
|
||||||
|
<lion-combobox name="combo" label="Default">
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Autocomplete
|
||||||
|
|
||||||
|
Below you will find an overview of all possible `autocomplete` behaviors and how they correspond
|
||||||
|
to the configurable values `none`, `list`, `inline` and `both`.
|
||||||
|
|
||||||
|
| | list | filter | focus | check | complete |
|
||||||
|
| -----: | :--: | :----: | :---: | :---: | :------: |
|
||||||
|
| none | ✓ | | | | |
|
||||||
|
| list | ✓ | ✓ | ✓ | ✓ | |
|
||||||
|
| inline | ✓ | | ✓ | ✓ | ✓ |
|
||||||
|
| both | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
|
- **list** shows a list on keydown character press
|
||||||
|
- **filter** filters list of potential matches according to `matchmode` or provided `filterOptionCondition`
|
||||||
|
- **focus** automatically focuses closest match (makes it the activedescendant)
|
||||||
|
- **check** automatically checks/selects closest match when `selection-follows-focus` is enabled (this is the default configuration)
|
||||||
|
- **complete** completes the textbox value inline (the 'missing characters' will be added as selected text)
|
||||||
|
|
||||||
|
When `autocomplete="none"` is configured, the suggested options in the overlay are not filtered
|
||||||
|
based on the characters typed in the textbox.
|
||||||
|
Selection will happen manually by the user.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const autocompleteNone = () => html`
|
||||||
|
<lion-combobox name="combo" label="Autocomplete 'none'" autocomplete="none">
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
When `autocomplete="list"` is configured, it will filter listbox suggestions based on textbox value.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const autocompleteList = () => html`
|
||||||
|
<lion-combobox name="combo" label="Autocomplete 'list'" autocomplete="list">
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
When `autocomplete="inline"` is configured, it will present a value completion prediction inside the text input itself.
|
||||||
|
It does NOT filter list of potential matches.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const autocompleteInline = () => html`
|
||||||
|
<lion-combobox name="combo" label="Autocomplete 'inline'" autocomplete="inline">
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
When `autocomplete="both"` is configured, it combines the filtered list from `'list'` with the text input value completion prediction from `'inline'`.
|
||||||
|
This is the default value for `autocomplete`.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const autocompleteBoth = () => html`
|
||||||
|
<lion-combobox name="combo" label="Autocomplete 'both'" autocomplete="both">
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Match Mode
|
||||||
|
|
||||||
|
When `match-mode="begin"` is applied, the entered text in the textbox only filters
|
||||||
|
options whose values begin with the entered text. For instance, the entered text 'ch' will match
|
||||||
|
with value 'Chard', but not with 'Artichoke'.
|
||||||
|
By default `match-mode="all"` is applied. This will also match parts of a word.
|
||||||
|
So 'ch' will both match 'Chard' and 'Artichoke'.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const matchModeBegin = () => html`
|
||||||
|
<lion-combobox name="combo" label="Match Mode 'begin'" match-mode="begin">
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const matchModeAll = () => html`
|
||||||
|
<lion-combobox name="combo" label="Match Mode 'all'" match-mode="all">
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changing defaults
|
||||||
|
|
||||||
|
By default `selection-follows-focus` will be true (aligned with the
|
||||||
|
wai-aria examples and the natve `<datalist>`).
|
||||||
|
It is possible to disable this behavior, so the active/focused and checked/selected values
|
||||||
|
will be kept track of independently.
|
||||||
|
|
||||||
|
> Note that, (just like in a listbox), selection-follows-focus will never be applicable for
|
||||||
|
> multiselect comboboxes.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const noSelectionFollowsFocus = () => html`
|
||||||
|
<lion-combobox name="combo" label="No Selection Follows focus" .selectionFollowsFocus="${false}">
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
By default `rotate-keyboard-navigation` will be true (aligned with the
|
||||||
|
wai-aria examples and the natve `<datalist>`).
|
||||||
|
It is possible to disable this behavior, see example below
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const noRotateKeyboardNavigation = () => html`
|
||||||
|
<lion-combobox
|
||||||
|
name="combo"
|
||||||
|
label="No Rotate Keyboard Navigation"
|
||||||
|
.rotateKeyboardNavigation="${false}"
|
||||||
|
>
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
> Please note that the lion-combobox-selection-display below is not exposed and only serves
|
||||||
|
> as an example. The selection part of a multiselect combobox is not yet accessible, please keep
|
||||||
|
> in mind that for now, as a Subclasser, you would have to take care of this part yourself.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const multipleChoice = () => html`
|
||||||
|
<lion-combobox name="combo" label="Multiple" multiple-choice>
|
||||||
|
<lion-combobox-selection-display slot="selection-display"></lion-combobox-selection-display>
|
||||||
|
${listboxData.map(
|
||||||
|
(entry, i) =>
|
||||||
|
html` <lion-option .choiceValue="${entry}" ?checked=${i === 0}>${entry}</lion-option> `,
|
||||||
|
)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invoker button
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const invokerButton = () => html`
|
||||||
|
<lion-combobox
|
||||||
|
.modelValue="${listboxData[1]}"
|
||||||
|
autocomplete="none"
|
||||||
|
name="combo"
|
||||||
|
label="Invoker Button"
|
||||||
|
@click="${({ currentTarget: el }) => {
|
||||||
|
el.opened = !el.opened;
|
||||||
|
}}"
|
||||||
|
>
|
||||||
|
<button slot="suffix" type="button" tabindex="-1">▼</button>
|
||||||
|
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Listbox compatibility
|
||||||
|
|
||||||
|
All configurations that can be applied to `lion-listbox`, can be applied to `lion-combobox` as well.
|
||||||
|
See the [listbox documentation](?path=/docs/forms-listbox--main) for more information.
|
||||||
23
packages/combobox/docs/Subclassers.md
Normal file
23
packages/combobox/docs/Subclassers.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Combobox Extensions
|
||||||
|
|
||||||
|
```js script
|
||||||
|
import { html } from 'lit-html';
|
||||||
|
import './md-combobox/md-combobox.js';
|
||||||
|
import './md-combobox/md-input.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Forms/Combobox/Extensions',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const MaterialDesign = () => html`
|
||||||
|
<md-combobox name="combo" label="Default">
|
||||||
|
<md-option .choiceValue=${'Apple'}>Apple</md-option>
|
||||||
|
<md-option .choiceValue=${'Artichoke'}>Artichoke</md-option>
|
||||||
|
<md-option .choiceValue=${'Asparagus'}>Asparagus</md-option>
|
||||||
|
<md-option .choiceValue=${'Banana'}>Banana</md-option>
|
||||||
|
<md-option .choiceValue=${'Beets'}>Beets</md-option>
|
||||||
|
</md-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
197
packages/combobox/docs/lion-combobox-selection-display.js
Normal file
197
packages/combobox/docs/lion-combobox-selection-display.js
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
// eslint-disable-next-line max-classes-per-file
|
||||||
|
import { LitElement, html, css, nothing } from '@lion/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disclaimer: this is just an example component demoing the selection display of LionCombobox
|
||||||
|
* It needs an 'a11y plan' and tests before it could be released
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('../src/LionCombobox.js').LionCombobox} LionCombobox
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the wrapper containing the textbox that triggers the listbox with filtered options.
|
||||||
|
* Optionally, shows 'chips' that indicate the selection.
|
||||||
|
* Should be considered an internal/protected web component to be used in conjunction with
|
||||||
|
* LionCombobox
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class LionComboboxSelectionDisplay extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
comboboxElement: Object,
|
||||||
|
/**
|
||||||
|
* Can be used to visually indicate the next
|
||||||
|
*/
|
||||||
|
removeChipOnNextBackspace: Boolean,
|
||||||
|
selectedElements: Array,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles() {
|
||||||
|
return css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combobox__selection {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combobox__input {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-chip {
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #eee;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-chip--highlighted {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
* > ::slotted([slot='_textbox']) {
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure FocusMixin
|
||||||
|
*/
|
||||||
|
get _inputNode() {
|
||||||
|
return this.comboboxElement._inputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeSelectedElements() {
|
||||||
|
const { formElements, checkedIndex } = /** @type {LionCombobox} */ (this.comboboxElement);
|
||||||
|
const checkedIndexes = Array.isArray(checkedIndex) ? checkedIndex : [checkedIndex];
|
||||||
|
return formElements.filter((_, i) => checkedIndexes.includes(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
get multipleChoice() {
|
||||||
|
return this.comboboxElement?.multipleChoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this.__textboxOnKeyup = this.__textboxOnKeyup.bind(this);
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this.__restoreBackspace = this.__restoreBackspace.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
|
*/
|
||||||
|
firstUpdated(changedProperties) {
|
||||||
|
super.firstUpdated(changedProperties);
|
||||||
|
|
||||||
|
if (this.multipleChoice) {
|
||||||
|
this._inputNode.addEventListener('keyup', this.__textboxOnKeyup);
|
||||||
|
this._inputNode.addEventListener('focusout', this.__restoreBackspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
|
*/
|
||||||
|
onComboboxElementUpdated(changedProperties) {
|
||||||
|
if (changedProperties.has('modelValue')) {
|
||||||
|
this.selectedElements = this._computeSelectedElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whenever selectedElements are updated, makes sure that latest added elements
|
||||||
|
* are shown latest, and deleted elements respect existing order of chips.
|
||||||
|
*/
|
||||||
|
__reorderChips() {
|
||||||
|
const { selectedElements } = this;
|
||||||
|
if (this.__prevSelectedEls) {
|
||||||
|
const addedEls = selectedElements.filter(e => !this.__prevSelectedEls.includes(e));
|
||||||
|
const deletedEls = this.__prevSelectedEls.filter(e => !selectedElements.includes(e));
|
||||||
|
if (addedEls.length) {
|
||||||
|
this.selectedElements = [...this.__prevSelectedEls, ...addedEls];
|
||||||
|
} else if (deletedEls.length) {
|
||||||
|
deletedEls.forEach(delEl => {
|
||||||
|
this.__prevSelectedEls.splice(this.__prevSelectedEls.indexOf(delEl), 1);
|
||||||
|
});
|
||||||
|
this.selectedElements = this.__prevSelectedEls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.__prevSelectedEls = this.selectedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("@lion/listbox").LionOption} option
|
||||||
|
* @param {boolean} highlight
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
_selectedElementTemplate(option, highlight) {
|
||||||
|
return html`
|
||||||
|
<span class="selection-chip ${highlight ? 'selection-chip--highlighted' : ''}">
|
||||||
|
${option.value}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedElementsTemplate() {
|
||||||
|
if (!this.multipleChoice) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div class="combobox__selection">
|
||||||
|
${this.selectedElements.map((option, i) => {
|
||||||
|
const highlight = Boolean(
|
||||||
|
this.removeChipOnNextBackspace && i === this.selectedElements.length - 1,
|
||||||
|
);
|
||||||
|
return this._selectedElementTemplate(option, highlight);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html` ${this._selectedElementsTemplate()} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ key: string; }} ev
|
||||||
|
*/
|
||||||
|
__textboxOnKeyup(ev) {
|
||||||
|
// Why we handle here and not in LionComboboxInvoker:
|
||||||
|
// All selectedElements state truth should be kept here and should not go back
|
||||||
|
// and forth.
|
||||||
|
if (ev.key === 'Backspace') {
|
||||||
|
if (!this._inputNode.value) {
|
||||||
|
if (this.removeChipOnNextBackspace) {
|
||||||
|
this.selectedElements[this.selectedElements.length - 1].checked = false;
|
||||||
|
}
|
||||||
|
this.removeChipOnNextBackspace = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.removeChipOnNextBackspace = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: move to LionCombobox
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
this._inputNode.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__restoreBackspace() {
|
||||||
|
this.removeChipOnNextBackspace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('lion-combobox-selection-display', LionComboboxSelectionDisplay);
|
||||||
340
packages/combobox/docs/md-combobox/MdFieldMixin.js
Normal file
340
packages/combobox/docs/md-combobox/MdFieldMixin.js
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
import { html, css, dedupeMixin } from '@lion/core';
|
||||||
|
|
||||||
|
export const MdFieldMixin = dedupeMixin(
|
||||||
|
superclass =>
|
||||||
|
class extends superclass {
|
||||||
|
static get styles() {
|
||||||
|
return [
|
||||||
|
...super.styles,
|
||||||
|
css`
|
||||||
|
/** @configure FormControlMixin */
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
block | .form-field
|
||||||
|
======================= */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================
|
||||||
|
element | .form-field__label
|
||||||
|
========================== */
|
||||||
|
|
||||||
|
.form-field__label ::slotted(label) {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-color, #545454);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([disabled]) .form-field__label ::slotted(label) {
|
||||||
|
color: var(--disabled-text-color, lightgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field__label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 0;
|
||||||
|
font: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: perspective(100px);
|
||||||
|
-ms-transform: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1),
|
||||||
|
color 0.4s cubic-bezier(0.25, 0.8, 0.25, 1),
|
||||||
|
width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
/* z-index: 1; */
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([focused]) .form-field__label,
|
||||||
|
:host([filled]) .form-field__label {
|
||||||
|
transform: translateY(-1.28125em) scale(0.75) perspective(100px) translateZ(0.001px);
|
||||||
|
width: 133.333333333333333333%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([focused]) .form-field__label {
|
||||||
|
color: var(--color-primary, royalblue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .form-field__help-text
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.form-field__help-text {
|
||||||
|
visibility: hidden;
|
||||||
|
margin-top: 8px;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.8em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([disabled]) .form-field__help-text ::slotted(*) {
|
||||||
|
color: var(--disabled-text-color, lightgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([focused]) .form-field__help-text {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([shows-feedback-for~='error']) .form-field__help-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .form-field__feedback
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.form-field__feedback {
|
||||||
|
margin-top: 8px;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.8em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([shows-feedback-for~='error']) .form-field__feedback {
|
||||||
|
color: var(--color-error, red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .input-group
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .input-group__container
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.input-group__container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .input-group__input
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.input-group__input {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | [slot="input"]
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
* > ::slotted([slot='input']) {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 1%;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary-text-color, #333333);
|
||||||
|
background: transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([disabled])
|
||||||
|
.input-group__container
|
||||||
|
> .input-group__input
|
||||||
|
::slotted([slot='input']) {
|
||||||
|
color: var(--disabled-text-color, lightgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .input-group__prefix,
|
||||||
|
element | .input-group__suffix
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.input-group__prefix,
|
||||||
|
.input-group__suffix {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group__prefix ::slotted(*),
|
||||||
|
.input-group__suffix ::slotted(*) {
|
||||||
|
align-self: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group__container > .input-group__prefix ::slotted(button),
|
||||||
|
.input-group__container > .input-group__suffix ::slotted(button) {
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group__container > .input-group__prefix ::slotted(button)::after,
|
||||||
|
.input-group__container > .input-group__suffix ::slotted(button)::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: radial-gradient(circle, #000 10%, transparent 10.01%);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: 50%;
|
||||||
|
transform: scale(10, 10);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.25s, opacity 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group__container > .input-group__prefix ::slotted(button:active)::after,
|
||||||
|
.input-group__container > .input-group__suffix ::slotted(button:active)::after {
|
||||||
|
transform: scale(0, 0);
|
||||||
|
opacity: 0.2;
|
||||||
|
transition: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==== state | :focus ==== */
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .input-group__before,
|
||||||
|
element | .input-group__after
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.input-group__before,
|
||||||
|
.input-group__after {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group__before ::slotted(*),
|
||||||
|
.input-group__after ::slotted(*) {
|
||||||
|
align-self: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group__before ::slotted(*) {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group__after ::slotted(*) {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @enhance FormControlMixin */
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .md-input__underline
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.md-input__underline {
|
||||||
|
position: absolute;
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.42);
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([disabled]) .md-input__underline {
|
||||||
|
border-top: 1px var(--disabled-text-color, lightgray) dashed;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([shows-feedback-for~='error']) .md-input__underline {
|
||||||
|
background-color: var(--color-error, red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================
|
||||||
|
element | .md-input__underline-ripple
|
||||||
|
============================== */
|
||||||
|
|
||||||
|
.md-input__underline-ripple {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
transform-origin: 50%;
|
||||||
|
transform: scaleX(0.5);
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: background-color 0.3s cubic-bezier(0.55, 0, 0.55, 0.2);
|
||||||
|
background-color: var(--color-primary, royalblue);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([focused]) .md-input__underline-ripple {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1),
|
||||||
|
opacity 0.1s cubic-bezier(0.25, 0.8, 0.25, 1),
|
||||||
|
background-color 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([shows-feedback-for~='error']) .md-input__underline-ripple {
|
||||||
|
background-color: var(--color-error, red);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override FormControlMixin
|
||||||
|
*/
|
||||||
|
_groupOneTemplate() {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override FormControlMixin
|
||||||
|
*/
|
||||||
|
_inputGroupInputTemplate() {
|
||||||
|
return html`
|
||||||
|
<div class="input-group__input">
|
||||||
|
${this._labelTemplate()}
|
||||||
|
<slot name="input"></slot>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enhance FormControlMixin
|
||||||
|
*/
|
||||||
|
_inputGroupTemplate() {
|
||||||
|
return html`
|
||||||
|
<div class="input-group">
|
||||||
|
${this._inputGroupBeforeTemplate()}
|
||||||
|
<div class="input-group__container">
|
||||||
|
${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()}
|
||||||
|
${this._inputGroupSuffixTemplate()}
|
||||||
|
<div class="md-input__underline">
|
||||||
|
<span class="md-input__underline-ripple"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${this._inputGroupAfterTemplate()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
89
packages/combobox/docs/md-combobox/md-combobox.js
Normal file
89
packages/combobox/docs/md-combobox/md-combobox.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { css, html } from '@lion/core';
|
||||||
|
import { LionOption } from '@lion/listbox';
|
||||||
|
import { LionCombobox } from '../../src/LionCombobox.js';
|
||||||
|
import { MdFieldMixin } from './MdFieldMixin.js';
|
||||||
|
import './style/md-ripple.js';
|
||||||
|
import './style/load-roboto.js';
|
||||||
|
|
||||||
|
// TODO: insert ink wc here
|
||||||
|
export class MdOption extends LionOption {
|
||||||
|
static get styles() {
|
||||||
|
return [
|
||||||
|
super.styles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([focused]) {
|
||||||
|
background: lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([active]) {
|
||||||
|
color: #1867c0 !important;
|
||||||
|
caret-color: #1867c0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::slotted(.md-highlight) {
|
||||||
|
color: rgba(0, 0, 0, 0.38);
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
* @param {string} matchingString
|
||||||
|
*/
|
||||||
|
onFilterMatch(matchingString) {
|
||||||
|
const { innerHTML } = this;
|
||||||
|
this.__originalInnerHTML = innerHTML;
|
||||||
|
this.innerHTML = innerHTML.replace(
|
||||||
|
new RegExp(`(${matchingString})`, 'i'),
|
||||||
|
`<span class="md-highlight">$1</span>`,
|
||||||
|
);
|
||||||
|
this.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
onFilterUnmatch() {
|
||||||
|
if (this.__originalInnerHTML) {
|
||||||
|
this.innerHTML = this.__originalInnerHTML;
|
||||||
|
}
|
||||||
|
this.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
${super.render()}
|
||||||
|
<md-ripple></md-ripple>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('md-option', MdOption);
|
||||||
|
|
||||||
|
export class MdCombobox extends MdFieldMixin(LionCombobox) {
|
||||||
|
static get styles() {
|
||||||
|
return [
|
||||||
|
super.styles,
|
||||||
|
css`
|
||||||
|
.input-group__container {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* > ::slotted([role='listbox']) {
|
||||||
|
box-shadow: 0 4px 6px 0 rgba(32, 33, 36, 0.28);
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('md-combobox', MdCombobox);
|
||||||
5
packages/combobox/docs/md-combobox/md-input.js
Normal file
5
packages/combobox/docs/md-combobox/md-input.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { LionInput } from '@lion/input';
|
||||||
|
import { MdFieldMixin } from './MdFieldMixin.js';
|
||||||
|
|
||||||
|
export class MdInput extends MdFieldMixin(LionInput) {}
|
||||||
|
customElements.define('md-input', MdInput);
|
||||||
6
packages/combobox/docs/md-combobox/style/load-roboto.js
Normal file
6
packages/combobox/docs/md-combobox/style/load-roboto.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// We don't have access to our main index html, so let's add Roboto font like this
|
||||||
|
const linkNode = document.createElement('link');
|
||||||
|
linkNode.href = 'https://fonts.googleapis.com/css?family=Roboto:300,400,500';
|
||||||
|
linkNode.rel = 'stylesheet';
|
||||||
|
linkNode.type = 'text/css';
|
||||||
|
document.head.appendChild(linkNode);
|
||||||
81
packages/combobox/docs/md-combobox/style/md-ripple.js
Normal file
81
packages/combobox/docs/md-combobox/style/md-ripple.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { html, css, LitElement } from '@lion/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material Design Ripple Element
|
||||||
|
*
|
||||||
|
* - should be placed in a 'positioned' context (having positon: (realtive/fixed/absolute))
|
||||||
|
*/
|
||||||
|
class MdRipple extends LitElement {
|
||||||
|
static get styles() {
|
||||||
|
return [
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: 0.1s ease-in;
|
||||||
|
user-select: none;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ripple {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 100%;
|
||||||
|
position: relative;
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate {
|
||||||
|
animation: ripple 0.4s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ripple {
|
||||||
|
100% {
|
||||||
|
transform: scale(12);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html` <div id="ripple"></div> `;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated(c) {
|
||||||
|
super.firstUpdated(c);
|
||||||
|
this._ripple = this.shadowRoot.querySelector('#ripple');
|
||||||
|
this._ripple.style.cssText = `width: ${this.offsetHeight}px; height: ${this.offsetHeight}px;`;
|
||||||
|
this.__onRipple = this.__onRipple.bind(this);
|
||||||
|
this.addEventListener('mousedown', this.__onRipple);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.removeEventListener('mousedown', this.__onRipple);
|
||||||
|
}
|
||||||
|
|
||||||
|
__onRipple(e) {
|
||||||
|
this._ripple.classList.remove('animate');
|
||||||
|
const rect = this.getBoundingClientRect();
|
||||||
|
const offset = {
|
||||||
|
top: rect.top + document.body.scrollTop,
|
||||||
|
left: rect.left + document.body.scrollLeft,
|
||||||
|
};
|
||||||
|
this._ripple.style.left = `${
|
||||||
|
parseInt(e.pageX - offset.left, 10) - this._ripple.offsetWidth / 2
|
||||||
|
}px`;
|
||||||
|
this._ripple.style.top = `${
|
||||||
|
parseInt(e.pageY - offset.top, 10) - this._ripple.offsetHeight / 2
|
||||||
|
}px`;
|
||||||
|
this._ripple.classList.add('animate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('md-ripple', MdRipple);
|
||||||
1
packages/combobox/index.js
Normal file
1
packages/combobox/index.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { LionCombobox } from './src/LionCombobox.js';
|
||||||
3
packages/combobox/lion-combobox.js
Normal file
3
packages/combobox/lion-combobox.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { LionCombobox } from './src/LionCombobox.js';
|
||||||
|
|
||||||
|
customElements.define('lion-combobox', LionCombobox);
|
||||||
48
packages/combobox/package.json
Normal file
48
packages/combobox/package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "@lion/combobox",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "A widget made up of a single-line textbox and an associated pop-up listbox element",
|
||||||
|
"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/combobox"
|
||||||
|
},
|
||||||
|
"main": "index.js",
|
||||||
|
"module": "index.js",
|
||||||
|
"files": [
|
||||||
|
"*.d.ts",
|
||||||
|
"*.js",
|
||||||
|
"docs",
|
||||||
|
"src",
|
||||||
|
"test",
|
||||||
|
"test-helpers",
|
||||||
|
"translations",
|
||||||
|
"types"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"prepublishOnly": "../../scripts/npm-prepublish.js",
|
||||||
|
"test": "cd ../../ && yarn test:browser --grep \"packages/combobox/test/**/*.test.js\"",
|
||||||
|
"test:watch": "cd ../../ && yarn test:browser:watch --grep \"packages/combobox/test/**/*.test.js\""
|
||||||
|
},
|
||||||
|
"sideEffects": [
|
||||||
|
"lion-combobox.js"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@lion/core": "0.12.0",
|
||||||
|
"@lion/form-core": "0.6.1",
|
||||||
|
"@lion/listbox": "0.1.1",
|
||||||
|
"@lion/overlays": "0.19.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"combobox",
|
||||||
|
"form",
|
||||||
|
"lion",
|
||||||
|
"web-components"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
653
packages/combobox/src/LionCombobox.js
Normal file
653
packages/combobox/src/LionCombobox.js
Normal file
|
|
@ -0,0 +1,653 @@
|
||||||
|
// eslint-disable-next-line max-classes-per-file
|
||||||
|
import { html, css, browserDetection } from '@lion/core';
|
||||||
|
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
|
||||||
|
import { LionListbox } from '@lion/listbox';
|
||||||
|
|
||||||
|
// TODO: make ListboxOverlayMixin that is shared between SelectRich and Combobox
|
||||||
|
// TODO: extract option matching based on 'typed character cache' and share that logic
|
||||||
|
// on Listbox or ListNavigationWithActiveDescendantMixin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('@lion/listbox').LionOption} LionOption
|
||||||
|
* @typedef {import('@lion/listbox').LionOptions} LionOptions
|
||||||
|
* @typedef {import('@lion/overlays/types/OverlayConfig').OverlayConfig} OverlayConfig
|
||||||
|
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LionCombobox: implements the wai-aria combobox design pattern and integrates it as a Lion
|
||||||
|
* FormControl
|
||||||
|
*/
|
||||||
|
export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
autocomplete: { type: String, reflect: true },
|
||||||
|
matchMode: {
|
||||||
|
type: String,
|
||||||
|
attribute: 'match-mode',
|
||||||
|
},
|
||||||
|
__shouldAutocompleteNextUpdate: Boolean,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles() {
|
||||||
|
return [
|
||||||
|
super.styles,
|
||||||
|
css`
|
||||||
|
.input-group__input {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group__container {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
* > ::slotted([slot='input']) {
|
||||||
|
outline: none;
|
||||||
|
flex: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
/* border-bottom: 1px solid; */
|
||||||
|
}
|
||||||
|
|
||||||
|
* > ::slotted([role='listbox']) {
|
||||||
|
max-height: 200px;
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 1;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override FormControlMixin
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
_inputGroupInputTemplate() {
|
||||||
|
return html`
|
||||||
|
<div class="input-group__input">
|
||||||
|
<slot name="selection-display"></slot>
|
||||||
|
<slot name="input"></slot>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
_overlayListboxTemplate() {
|
||||||
|
return html`
|
||||||
|
<slot name="_overlay-shadow-outlet"></slot>
|
||||||
|
<div id="overlay-content-node-wrapper" role="dialog">
|
||||||
|
<slot name="listbox"></slot>
|
||||||
|
</div>
|
||||||
|
<slot id="options-outlet"></slot>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enhance FormControlMixin
|
||||||
|
*/
|
||||||
|
_groupTwoTemplate() {
|
||||||
|
return html` ${super._groupTwoTemplate()} ${this._overlayListboxTemplate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {SlotsMap}
|
||||||
|
*/
|
||||||
|
get slots() {
|
||||||
|
return {
|
||||||
|
...super.slots,
|
||||||
|
/**
|
||||||
|
* The interactive element that can receive focus
|
||||||
|
*/
|
||||||
|
input: () => {
|
||||||
|
if (this._ariaVersion === '1.1') {
|
||||||
|
/**
|
||||||
|
* According to the 1.1 specs, the input should be either wrapped in an element with
|
||||||
|
* [role=combobox], or element with [role=combobox] should have [aria-owns=input-id].
|
||||||
|
* For best cross browser compatibility, we choose the first option.
|
||||||
|
*/
|
||||||
|
const combobox = document.createElement('div');
|
||||||
|
const textbox = document.createElement('input');
|
||||||
|
|
||||||
|
// Reset textbox styles so that it 'merges' with parent [role=combobox]
|
||||||
|
// that is styled by Subclassers
|
||||||
|
textbox.style.cssText = `
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;`;
|
||||||
|
|
||||||
|
combobox.appendChild(textbox);
|
||||||
|
return combobox;
|
||||||
|
}
|
||||||
|
// ._ariaVersion === '1.0'
|
||||||
|
/**
|
||||||
|
* For browsers not supporting aria 1.1 spec, we implement the 1.0 spec.
|
||||||
|
* That means we have one (input) element that has [role=combobox]
|
||||||
|
*/
|
||||||
|
return document.createElement('input');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* As opposed to our parent (LionListbox), the end user doesn't interact with the
|
||||||
|
* element that has [role=listbox] (in a combobox, it has no tabindex), but with
|
||||||
|
* the text box (<input>) element.
|
||||||
|
*/
|
||||||
|
listbox: super.slots.input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper with combobox role for the text input that the end user controls the listbox with.
|
||||||
|
* @type {HTMLElement}
|
||||||
|
*/
|
||||||
|
get _comboboxNode() {
|
||||||
|
return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {HTMLElement | null}
|
||||||
|
*/
|
||||||
|
get _selectionDisplayNode() {
|
||||||
|
return this.querySelector('[slot="selection-display"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure FormControlMixin
|
||||||
|
* Will tell FormControlMixin that a11y wrt labels / descriptions / feedback
|
||||||
|
* should be applied here.
|
||||||
|
*/
|
||||||
|
get _inputNode() {
|
||||||
|
if (this._ariaVersion === '1.1') {
|
||||||
|
return /** @type {HTMLInputElement} */ (this._comboboxNode.querySelector('input'));
|
||||||
|
}
|
||||||
|
return /** @type {HTMLInputElement} */ (this._comboboxNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure OverlayMixin
|
||||||
|
*/
|
||||||
|
get _overlayContentNode() {
|
||||||
|
return this._listboxNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure OverlayMixin
|
||||||
|
*/
|
||||||
|
get _overlayReferenceNode() {
|
||||||
|
return this.shadowRoot.querySelector('.input-group__container');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure OverlayMixin
|
||||||
|
*/
|
||||||
|
get _overlayInvokerNode() {
|
||||||
|
return this._inputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure ListboxMixin
|
||||||
|
*/
|
||||||
|
get _listboxNode() {
|
||||||
|
return /** @type {LionOptions} */ ((this._overlayCtrl && this._overlayCtrl.contentNode) ||
|
||||||
|
Array.from(this.children).find(child => child.slot === 'listbox'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure ListboxMixin
|
||||||
|
*/
|
||||||
|
get _activeDescendantOwnerNode() {
|
||||||
|
return this._inputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
/**
|
||||||
|
* When "list", will filter listbox suggestions based on textbox value.
|
||||||
|
* When "both", an inline completion string will be added to the textbox as well.
|
||||||
|
* @type {'none'|'list'|'inline'|'both'}
|
||||||
|
*/
|
||||||
|
this.autocomplete = 'both';
|
||||||
|
/**
|
||||||
|
* When typing in the textbox, will by default be set on 'begin',
|
||||||
|
* only matching the beginning part in suggestion list.
|
||||||
|
* => 'a' will match 'apple' from ['apple', 'pear', 'citrus'].
|
||||||
|
* When set to 'all', will match middle of the word as well
|
||||||
|
* => 'a' will match 'apple' and 'pear'
|
||||||
|
* @type {'begin'|'all'}
|
||||||
|
*/
|
||||||
|
this.matchMode = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure ListboxMixin: the wai-aria pattern and <datalist> rotate
|
||||||
|
*/
|
||||||
|
this.rotateKeyboardNavigation = true;
|
||||||
|
/**
|
||||||
|
* @configure ListboxMixin: the wai-aria pattern and <datalist> have selection follow focus
|
||||||
|
*/
|
||||||
|
this.selectionFollowsFocus = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For optimal support, we allow aria v1.1 on newer browsers
|
||||||
|
* @type {'1.1'|'1.0'}
|
||||||
|
*/
|
||||||
|
this._ariaVersion = browserDetection.isChromium ? '1.1' : '1.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure ListboxMixin
|
||||||
|
*/
|
||||||
|
this._listboxReceivesNoFocus = true;
|
||||||
|
|
||||||
|
this.__prevCboxValueNonSelected = '';
|
||||||
|
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this.__showOverlay = this.__showOverlay.bind(this);
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this._textboxOnInput = this._textboxOnInput.bind(this);
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this._textboxOnKeydown = this._textboxOnKeydown.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
if (this._selectionDisplayNode) {
|
||||||
|
this._selectionDisplayNode.comboboxElement = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'disabled'|'modelValue'|'readOnly'} name
|
||||||
|
* @param {unknown} oldValue
|
||||||
|
*/
|
||||||
|
requestUpdateInternal(name, oldValue) {
|
||||||
|
super.requestUpdateInternal(name, oldValue);
|
||||||
|
if (name === 'disabled' || name === 'readOnly') {
|
||||||
|
this.__setComboboxDisabledAndReadOnly();
|
||||||
|
}
|
||||||
|
if (name === 'modelValue' && this.modelValue !== oldValue) {
|
||||||
|
if (this.modelValue) {
|
||||||
|
this._setTextboxValue(this.modelValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
|
*/
|
||||||
|
updated(changedProperties) {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
|
||||||
|
if (changedProperties.has('opened')) {
|
||||||
|
if (this.opened) {
|
||||||
|
// Note we always start with -1 as a 'fundament'
|
||||||
|
// For [autocomplete="inline|both"] activeIndex might be changed by
|
||||||
|
this.activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.opened && changedProperties.get('opened') !== undefined) {
|
||||||
|
this._syncCheckedWithTextboxOnInteraction();
|
||||||
|
this.activeIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changedProperties.has('autocomplete')) {
|
||||||
|
this._inputNode.setAttribute('aria-autocomplete', this.autocomplete);
|
||||||
|
}
|
||||||
|
if (changedProperties.has('disabled')) {
|
||||||
|
this.setAttribute('aria-disabled', `${this.disabled}`); // create mixin if we need it in more places
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
changedProperties.has('__shouldAutocompleteNextUpdate') &&
|
||||||
|
this.__shouldAutocompleteNextUpdate
|
||||||
|
) {
|
||||||
|
// Only update list in render cycle
|
||||||
|
this._handleAutocompletion({
|
||||||
|
curValue: this._inputNode.value,
|
||||||
|
prevValue: this.__prevCboxValueNonSelected,
|
||||||
|
});
|
||||||
|
this.__shouldAutocompleteNextUpdate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._selectionDisplayNode) {
|
||||||
|
this._selectionDisplayNode.onComboboxElementUpdated(changedProperties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @param {LionOption} option
|
||||||
|
* @param {string} curValue current ._inputNode value
|
||||||
|
*/
|
||||||
|
filterOptionCondition(option, curValue) {
|
||||||
|
let idx = -1;
|
||||||
|
if (typeof option.choiceValue === 'string' && typeof curValue === 'string') {
|
||||||
|
idx = option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.matchMode === 'all') {
|
||||||
|
return idx > -1; // matches part of word
|
||||||
|
}
|
||||||
|
return idx === 0; // matches beginning of value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Event} ev
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
_textboxOnInput(ev) {
|
||||||
|
// this.__cboxInputValue = /** @type {LionOption} */ (ev.target).value;
|
||||||
|
// Schedules autocompletion of options
|
||||||
|
this.__shouldAutocompleteNextUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Event} ev
|
||||||
|
*/
|
||||||
|
_textboxOnKeydown(ev) {
|
||||||
|
if (ev.key === 'Tab') {
|
||||||
|
this.opened = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {MouseEvent} ev
|
||||||
|
*/
|
||||||
|
_listboxOnClick(ev) {
|
||||||
|
super._listboxOnClick(ev);
|
||||||
|
if (!this.multipleChoice) {
|
||||||
|
this.activeIndex = -1;
|
||||||
|
this.opened = false;
|
||||||
|
}
|
||||||
|
this._inputNode.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setTextboxValue(v) {
|
||||||
|
this._inputNode.value = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For multiple choice, a subclasser could do something like:
|
||||||
|
* @example
|
||||||
|
* _syncCheckedWithTextboxOnInteraction() {
|
||||||
|
* super._syncCheckedWithTextboxOnInteraction();
|
||||||
|
* if (this.multipleChoice) {
|
||||||
|
* this._inputNode.value = this.checkedElements.map(o => o.value).join(', ');
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* @overridable
|
||||||
|
*/
|
||||||
|
_syncCheckedWithTextboxOnInteraction() {
|
||||||
|
if (!this.multipleChoice && this._inputNode.value === '') {
|
||||||
|
this._uncheckChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.multipleChoice && this.checkedIndex !== -1) {
|
||||||
|
this._inputNode.value = this.formElements[/** @type {number} */ (this.checkedIndex)].value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable no-param-reassign, class-methods-use-this */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @param {LionOption & {__originalInnerHTML?:string}} option
|
||||||
|
* @param {string} matchingString
|
||||||
|
*/
|
||||||
|
_onFilterMatch(option, matchingString) {
|
||||||
|
const { innerHTML } = option;
|
||||||
|
option.__originalInnerHTML = innerHTML;
|
||||||
|
const newInnerHTML = innerHTML.replace(new RegExp(`(${matchingString})`, 'i'), `<b>$1</b>`);
|
||||||
|
// For Safari, we need to add a label to the element
|
||||||
|
option.innerHTML = `<span aria-label="${option.textContent}">${newInnerHTML}</span>`;
|
||||||
|
// Alternatively, an extension can add an animation here
|
||||||
|
option.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @param {LionOption & {__originalInnerHTML?:string}} option
|
||||||
|
* @param {string} [curValue]
|
||||||
|
* @param {string} [prevValue]
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
_onFilterUnmatch(option, curValue, prevValue) {
|
||||||
|
if (option.__originalInnerHTML) {
|
||||||
|
option.innerHTML = option.__originalInnerHTML;
|
||||||
|
}
|
||||||
|
// Alternatively, an extension can add an animation here
|
||||||
|
option.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeUserIntendsAutoFill({ prevValue, curValue }) {
|
||||||
|
const userIsAddingChars = prevValue.length < curValue.length;
|
||||||
|
const userStartsNewWord = prevValue.length && curValue.length && prevValue[0] !== curValue[0];
|
||||||
|
return userIsAddingChars || userStartsNewWord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-enable no-param-reassign, class-methods-use-this */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches visibility of listbox options against current ._inputNode contents
|
||||||
|
* @param {object} config
|
||||||
|
* @param {string} config.curValue current ._inputNode value
|
||||||
|
* @param {string} config.prevValue previous ._inputNode value
|
||||||
|
*/
|
||||||
|
_handleAutocompletion({ curValue, prevValue }) {
|
||||||
|
if (this.autocomplete === 'none') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filtered list of options that will match in this autocompletion cycle
|
||||||
|
* @type {LionOption[]}
|
||||||
|
*/
|
||||||
|
const visibleOptions = [];
|
||||||
|
let hasAutoFilled = false;
|
||||||
|
const userIntendsAutoFill = this._computeUserIntendsAutoFill({ prevValue, curValue });
|
||||||
|
const isAutoFillCandidate = this.autocomplete === 'both' || this.autocomplete === 'inline';
|
||||||
|
|
||||||
|
/** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */
|
||||||
|
this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, i) => {
|
||||||
|
const show =
|
||||||
|
this.autocomplete === 'inline' ? true : this.filterOptionCondition(option, curValue);
|
||||||
|
|
||||||
|
// [1]. Synchronize ._inputNode value and active descendant with closest match
|
||||||
|
if (isAutoFillCandidate) {
|
||||||
|
const stringValues = typeof option.choiceValue === 'string' && typeof curValue === 'string';
|
||||||
|
const beginsWith =
|
||||||
|
stringValues && option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase()) === 0;
|
||||||
|
const shouldAutoFill =
|
||||||
|
beginsWith && !hasAutoFilled && show && userIntendsAutoFill && !option.disabled;
|
||||||
|
|
||||||
|
if (shouldAutoFill) {
|
||||||
|
const prevLen = this._inputNode.value.length;
|
||||||
|
this._inputNode.value = option.choiceValue;
|
||||||
|
this._inputNode.selectionStart = prevLen;
|
||||||
|
this._inputNode.selectionEnd = this._inputNode.value.length;
|
||||||
|
this.activeIndex = i;
|
||||||
|
if (this.selectionFollowsFocus && !this.multipleChoice) {
|
||||||
|
this.setCheckedIndex(this.activeIndex);
|
||||||
|
}
|
||||||
|
hasAutoFilled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [2]. Cleanup previous matching states
|
||||||
|
if (option.onFilterUnmatch) {
|
||||||
|
option.onFilterUnmatch(curValue, prevValue);
|
||||||
|
} else {
|
||||||
|
this._onFilterUnmatch(option, curValue, prevValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [3]. If ._inputNode is empty, no filtering will be applied
|
||||||
|
if (!curValue) {
|
||||||
|
visibleOptions.push(option);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [4]. Cleanup previous visibility and a11y states
|
||||||
|
option.setAttribute('aria-hidden', 'true');
|
||||||
|
option.removeAttribute('aria-posinset');
|
||||||
|
option.removeAttribute('aria-setsize');
|
||||||
|
|
||||||
|
// [5]. Add options that meet matching criteria
|
||||||
|
if (show) {
|
||||||
|
visibleOptions.push(option);
|
||||||
|
if (option.onFilterMatch) {
|
||||||
|
option.onFilterMatch(curValue);
|
||||||
|
} else {
|
||||||
|
this._onFilterMatch(option, curValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// [6]. enable a11y, visibility and user interaction for visible options
|
||||||
|
const setSize = visibleOptions.length;
|
||||||
|
visibleOptions.forEach((option, idx) => {
|
||||||
|
option.setAttribute('aria-posinset', `${idx + 1}`);
|
||||||
|
option.setAttribute('aria-setsize', `${setSize}`);
|
||||||
|
option.removeAttribute('aria-hidden');
|
||||||
|
});
|
||||||
|
/** @type {number} */
|
||||||
|
const { selectionStart } = this._inputNode;
|
||||||
|
this.__prevCboxValueNonSelected = curValue.slice(0, selectionStart);
|
||||||
|
|
||||||
|
if (this._overlayCtrl && this._overlayCtrl._popper) {
|
||||||
|
this._overlayCtrl._popper.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAutoFilled && isAutoFillCandidate && !this.multipleChoice) {
|
||||||
|
// This means there is no match for checkedIndex
|
||||||
|
this.checkedIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enhance ListboxMixin
|
||||||
|
*/
|
||||||
|
_setupListboxNode() {
|
||||||
|
super._setupListboxNode();
|
||||||
|
// Only the textbox should be focusable
|
||||||
|
this._listboxNode.removeAttribute('tabindex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure OverlayMixin
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
_defineOverlayConfig() {
|
||||||
|
return /** @type {OverlayConfig} */ ({
|
||||||
|
...withDropdownConfig(),
|
||||||
|
elementToFocusAfterHide: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enhance OverlayMixin
|
||||||
|
*/
|
||||||
|
_setupOverlayCtrl() {
|
||||||
|
super._setupOverlayCtrl();
|
||||||
|
this.__initFilterListbox();
|
||||||
|
this.__setupCombobox();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enhance OverlayMixin
|
||||||
|
*/
|
||||||
|
_setupOpenCloseListeners() {
|
||||||
|
super._setupOpenCloseListeners();
|
||||||
|
this._overlayInvokerNode.addEventListener('keydown', this.__showOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enhance OverlayMixin
|
||||||
|
*/
|
||||||
|
_teardownOpenCloseListeners() {
|
||||||
|
super._teardownOpenCloseListeners();
|
||||||
|
this._overlayInvokerNode.removeEventListener('keydown', this.__showOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {KeyboardEvent} ev
|
||||||
|
*/
|
||||||
|
_listboxOnKeyDown(ev) {
|
||||||
|
super._listboxOnKeyDown(ev);
|
||||||
|
const { key } = ev;
|
||||||
|
switch (key) {
|
||||||
|
case 'Escape':
|
||||||
|
this.opened = false;
|
||||||
|
this.__shouldAutocompleteNextUpdate = true;
|
||||||
|
this._setTextboxValue('');
|
||||||
|
// this.checkedIndex = -1;
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
if (!this.formElements[this.activeIndex]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// this._syncCheckedWithTextboxOnInteraction();
|
||||||
|
if (!this.multipleChoice) {
|
||||||
|
this.opened = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
/* no default */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__initFilterListbox() {
|
||||||
|
this._handleAutocompletion({
|
||||||
|
curValue: this._inputNode.value,
|
||||||
|
prevValue: this.__prevCboxValueNonSelected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
__setComboboxDisabledAndReadOnly() {
|
||||||
|
if (this._comboboxNode) {
|
||||||
|
this._comboboxNode.setAttribute('disabled', `${this.disabled}`);
|
||||||
|
this._comboboxNode.setAttribute('readonly', `${this.readOnly}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__setupCombobox() {
|
||||||
|
// With regard to accessibility: aria-expanded and -labelledby will
|
||||||
|
// be handled by OverlatMixin and FormControlMixin respectively.
|
||||||
|
|
||||||
|
this._comboboxNode.setAttribute('role', 'combobox');
|
||||||
|
this._comboboxNode.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
this._inputNode.setAttribute('aria-autocomplete', this.autocomplete);
|
||||||
|
|
||||||
|
if (this._ariaVersion === '1.1') {
|
||||||
|
this._comboboxNode.setAttribute('aria-owns', this._listboxNode.id);
|
||||||
|
this._inputNode.setAttribute('aria-controls', this._listboxNode.id);
|
||||||
|
} else {
|
||||||
|
this._inputNode.setAttribute('aria-owns', this._listboxNode.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._listboxNode.setAttribute('aria-labelledby', this._labelNode.id);
|
||||||
|
|
||||||
|
this._inputNode.addEventListener('keydown', this._listboxOnKeyDown);
|
||||||
|
this._inputNode.addEventListener('input', this._textboxOnInput);
|
||||||
|
this._inputNode.addEventListener('keydown', this._textboxOnKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
__teardownCombobox() {
|
||||||
|
this._inputNode.removeEventListener('keydown', this._listboxOnKeyDown);
|
||||||
|
this._inputNode.removeEventListener('input', this._textboxOnInput);
|
||||||
|
this._inputNode.removeEventListener('keydown', this._textboxOnKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {KeyboardEvent} ev
|
||||||
|
*/
|
||||||
|
__showOverlay(ev) {
|
||||||
|
if (ev.key === 'Tab' || ev.key === 'Esc' || ev.key === 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.opened = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { runListboxMixinSuite } from '@lion/listbox/test-suites/ListboxMixin.suite.js';
|
||||||
|
import '../lion-combobox.js';
|
||||||
|
|
||||||
|
runListboxMixinSuite({ tagString: 'lion-combobox' });
|
||||||
1021
packages/combobox/test/lion-combobox.test.js
Normal file
1021
packages/combobox/test/lion-combobox.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,41 @@
|
||||||
const isIE11 = /Trident/.test(window.navigator.userAgent);
|
/**
|
||||||
|
* From https://stackoverflow.com/questions/4565112/javascript-how-to-find-out-if-the-user-browser-is-chrome
|
||||||
|
* @param {string} [flavor]
|
||||||
|
*/
|
||||||
|
function checkChrome(flavor = 'google-chrome') {
|
||||||
|
// @ts-ignore
|
||||||
|
const isChromium = window.chrome;
|
||||||
|
if (flavor === 'chromium') {
|
||||||
|
return isChromium;
|
||||||
|
}
|
||||||
|
const winNav = window.navigator;
|
||||||
|
const vendorName = winNav.vendor;
|
||||||
|
// @ts-ignore
|
||||||
|
const isOpera = typeof window.opr !== 'undefined';
|
||||||
|
const isIEedge = winNav.userAgent.indexOf('Edge') > -1;
|
||||||
|
const isIOSChrome = winNav.userAgent.match('CriOS');
|
||||||
|
|
||||||
|
if (flavor === 'ios') {
|
||||||
|
return isIOSChrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flavor === 'google-chrome') {
|
||||||
|
return (
|
||||||
|
isChromium !== null &&
|
||||||
|
typeof isChromium !== 'undefined' &&
|
||||||
|
vendorName === 'Google Inc.' &&
|
||||||
|
isOpera === false &&
|
||||||
|
isIEedge === false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const browserDetection = {
|
export const browserDetection = {
|
||||||
isIE11,
|
isIE11: /Trident/.test(window.navigator.userAgent),
|
||||||
|
isChrome: checkChrome(),
|
||||||
|
isIOSChrome: checkChrome('ios'),
|
||||||
|
isChromium: checkChrome('chromium'),
|
||||||
|
isMac: navigator.appVersion.indexOf('Mac') !== -1,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
23
packages/core/src/closestPolyfill.js
Normal file
23
packages/core/src/closestPolyfill.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From: https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!Element.prototype.matches) {
|
||||||
|
Element.prototype.matches =
|
||||||
|
Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Element.prototype.closest) {
|
||||||
|
Element.prototype.closest = function (s) {
|
||||||
|
var el = this;
|
||||||
|
|
||||||
|
do {
|
||||||
|
if (Element.prototype.matches.call(el, s)) return el;
|
||||||
|
el = el.parentElement || el.parentNode;
|
||||||
|
} while (el !== null && el.nodeType === 1);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -175,35 +175,6 @@ const FormControlMixinImplementation = superclass =>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {import('lit-element').PropertyValues } changedProperties */
|
|
||||||
updated(changedProperties) {
|
|
||||||
super.updated(changedProperties);
|
|
||||||
|
|
||||||
if (changedProperties.has('_ariaLabelledNodes')) {
|
|
||||||
this.__reflectAriaAttr(
|
|
||||||
'aria-labelledby',
|
|
||||||
this._ariaLabelledNodes,
|
|
||||||
this.__reorderAriaLabelledNodes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changedProperties.has('_ariaDescribedNodes')) {
|
|
||||||
this.__reflectAriaAttr(
|
|
||||||
'aria-describedby',
|
|
||||||
this._ariaDescribedNodes,
|
|
||||||
this.__reorderAriaDescribedNodes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changedProperties.has('label')) {
|
|
||||||
this._onLabelChanged({ label: this.label });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changedProperties.has('helpText')) {
|
|
||||||
this._onHelpTextChanged({ helpText: this.helpText });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get _inputNode() {
|
get _inputNode() {
|
||||||
return this.__getDirectSlotChild('input');
|
return this.__getDirectSlotChild('input');
|
||||||
}
|
}
|
||||||
|
|
@ -230,13 +201,15 @@ const FormControlMixinImplementation = superclass =>
|
||||||
this._ariaLabelledNodes = [];
|
this._ariaLabelledNodes = [];
|
||||||
/** @type {HTMLElement[]} */
|
/** @type {HTMLElement[]} */
|
||||||
this._ariaDescribedNodes = [];
|
this._ariaDescribedNodes = [];
|
||||||
/** @type {'child' | 'choice-group' | 'fieldset'} */
|
/** @type {'child'|'choice-group'|'fieldset'} */
|
||||||
this._repropagationRole = 'child';
|
this._repropagationRole = 'child';
|
||||||
this._isRepropagationEndpoint = false;
|
this._isRepropagationEndpoint = false;
|
||||||
this.addEventListener(
|
this.addEventListener(
|
||||||
'model-value-changed',
|
'model-value-changed',
|
||||||
/** @type {EventListenerOrEventListenerObject} */ (this.__repropagateChildrenValues),
|
/** @type {EventListenerOrEventListenerObject} */ (this.__repropagateChildrenValues),
|
||||||
);
|
);
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this._onLabelClick = this._onLabelClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -244,6 +217,46 @@ const FormControlMixinImplementation = superclass =>
|
||||||
this._enhanceLightDomClasses();
|
this._enhanceLightDomClasses();
|
||||||
this._enhanceLightDomA11y();
|
this._enhanceLightDomA11y();
|
||||||
this._triggerInitialModelValueChangedEvent();
|
this._triggerInitialModelValueChangedEvent();
|
||||||
|
|
||||||
|
if (this._labelNode) {
|
||||||
|
this._labelNode.addEventListener('click', this._onLabelClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
if (this._labelNode) {
|
||||||
|
this._labelNode.removeEventListener('click', this._onLabelClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {import('lit-element').PropertyValues } changedProperties */
|
||||||
|
updated(changedProperties) {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
|
||||||
|
if (changedProperties.has('_ariaLabelledNodes')) {
|
||||||
|
this.__reflectAriaAttr(
|
||||||
|
'aria-labelledby',
|
||||||
|
this._ariaLabelledNodes,
|
||||||
|
this.__reorderAriaLabelledNodes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProperties.has('_ariaDescribedNodes')) {
|
||||||
|
this.__reflectAriaAttr(
|
||||||
|
'aria-describedby',
|
||||||
|
this._ariaDescribedNodes,
|
||||||
|
this.__reorderAriaDescribedNodes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProperties.has('label') && this._labelNode) {
|
||||||
|
this._labelNode.textContent = this.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProperties.has('helpText') && this._helpTextNode) {
|
||||||
|
this._helpTextNode.textContent = this.helpText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_triggerInitialModelValueChangedEvent() {
|
_triggerInitialModelValueChangedEvent() {
|
||||||
|
|
@ -321,26 +334,6 @@ const FormControlMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {{label:string}} opts
|
|
||||||
*/
|
|
||||||
_onLabelChanged({ label }) {
|
|
||||||
if (this._labelNode) {
|
|
||||||
this._labelNode.textContent = label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {{helpText:string}} opts
|
|
||||||
*/
|
|
||||||
_onHelpTextChanged({ helpText }) {
|
|
||||||
if (this._helpTextNode) {
|
|
||||||
this._helpTextNode.textContent = helpText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default Render Result:
|
* Default Render Result:
|
||||||
* <div class="form-field__group-one">
|
* <div class="form-field__group-one">
|
||||||
|
|
@ -820,6 +813,20 @@ const FormControlMixinImplementation = superclass =>
|
||||||
new CustomEvent('model-value-changed', { bubbles: true, detail: { formPath } }),
|
new CustomEvent('model-value-changed', { bubbles: true, detail: { formPath } }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* A Subclasser should only override this method if the interactive element
|
||||||
|
* ([slot=input]) is not a native element(like input, textarea, select)
|
||||||
|
* that already receives focus on label click.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* _onLabelClick() {
|
||||||
|
* this._invokerNode.focus();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
_onLabelClick() {}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormControlMixin = dedupeMixin(FormControlMixinImplementation);
|
export const FormControlMixin = dedupeMixin(FormControlMixinImplementation);
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ const ChoiceGroupMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
_getCheckedElements() {
|
_getCheckedElements() {
|
||||||
// We want to filter out disabled values out by default
|
// We want to filter out disabled values by default
|
||||||
return this.formElements.filter(el => el.checked && !el.disabled);
|
return this.formElements.filter(el => el.checked && !el.disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,7 @@ function mimicUserInput(formControl, newViewValue) {
|
||||||
export function runFormatMixinSuite(customConfig) {
|
export function runFormatMixinSuite(customConfig) {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
tagString: null,
|
tagString: null,
|
||||||
modelValueType: String,
|
childTagString: null,
|
||||||
suffix: '',
|
|
||||||
...customConfig,
|
...customConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,393 @@
|
||||||
|
import { LitElement } from '@lion/core';
|
||||||
|
import { LionInput } from '@lion/input';
|
||||||
|
import '@lion/fieldset/lion-fieldset.js';
|
||||||
|
import { FormGroupMixin, Required } from '@lion/form-core';
|
||||||
|
import { expect, html, fixture, unsafeStatic } from '@open-wc/testing';
|
||||||
|
import { ChoiceGroupMixin } from '../../src/choice-group/ChoiceGroupMixin.js';
|
||||||
|
import { ChoiceInputMixin } from '../../src/choice-group/ChoiceInputMixin.js';
|
||||||
|
|
||||||
|
class ChoiceInput extends ChoiceInputMixin(LionInput) {}
|
||||||
|
customElements.define('choice-group-input', ChoiceInput);
|
||||||
|
// @ts-expect-error
|
||||||
|
class ChoiceGroup extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {}
|
||||||
|
customElements.define('choice-group', ChoiceGroup);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ parentTagString?:string, childTagString?: string}} [config]
|
||||||
|
*/
|
||||||
|
export function runChoiceGroupMixinSuite({ parentTagString, childTagString } = {}) {
|
||||||
|
const cfg = {
|
||||||
|
parentTagString: parentTagString || 'choice-group',
|
||||||
|
childTagString: childTagString || 'choice-group-input',
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentTag = unsafeStatic(cfg.parentTagString);
|
||||||
|
const childTag = unsafeStatic(cfg.childTagString);
|
||||||
|
|
||||||
|
describe('ChoiceGroupMixin', () => {
|
||||||
|
it('has a single modelValue representing the currently checked radio value', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
expect(el.modelValue).to.equal('female');
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(el.modelValue).to.equal('male');
|
||||||
|
el.formElements[2].checked = true;
|
||||||
|
expect(el.modelValue).to.equal('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a single formattedValue representing the currently checked radio value', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
expect(el.formattedValue).to.equal('female');
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(el.formattedValue).to.equal('male');
|
||||||
|
el.formElements[2].checked = true;
|
||||||
|
expect(el.formattedValue).to.equal('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${childTag} .modelValue=${'Lara'}></${childTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
el.addFormElement(invalidChild);
|
||||||
|
}).to.throw(
|
||||||
|
'The choice-group name="gender" does not allow to register choice-group-input with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('automatically sets the name property of child radios to its own name', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.formElements[0].name).to.equal('gender');
|
||||||
|
expect(el.formElements[1].name).to.equal('gender');
|
||||||
|
|
||||||
|
const validChild = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
`));
|
||||||
|
el.appendChild(validChild);
|
||||||
|
|
||||||
|
expect(el.formElements[2].name).to.equal('gender');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if a child element with a different name than the group tries to register', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${childTag} name="foo" .choiceValue=${'male'}></${childTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
el.addFormElement(invalidChild);
|
||||||
|
}).to.throw(
|
||||||
|
'The choice-group name="gender" does not allow to register choice-group-input with custom names (name="foo" given)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can set initial modelValue on creation', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender" .modelValue=${'other'}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.modelValue).to.equal('other');
|
||||||
|
expect(el.formElements[2].checked).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can set initial serializedValue on creation', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender" .serializedValue=${'other'}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.serializedValue).to.equal('other');
|
||||||
|
expect(el.formElements[2].checked).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can set initial formattedValue on creation', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender" .formattedValue=${'other'}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.formattedValue).to.equal('other');
|
||||||
|
expect(el.formElements[2].checked).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can handle complex data via choiceValue', async () => {
|
||||||
|
const date = new Date(2018, 11, 24, 10, 33, 30, 0);
|
||||||
|
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="data">
|
||||||
|
<${childTag} .choiceValue=${{ some: 'data' }}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${date} checked></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.modelValue).to.equal(date);
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(el.modelValue).to.deep.equal({ some: 'data' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can handle 0 and empty string as valid values', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="data">
|
||||||
|
<${childTag} .choiceValue=${0} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${''}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.modelValue).to.equal(0);
|
||||||
|
el.formElements[1].checked = true;
|
||||||
|
expect(el.modelValue).to.equal('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can check a radio by supplying an available modelValue', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag}
|
||||||
|
.modelValue="${{ value: 'male', checked: false }}"
|
||||||
|
></${childTag}>
|
||||||
|
<${childTag}
|
||||||
|
.modelValue="${{ value: 'female', checked: true }}"
|
||||||
|
></${childTag}>
|
||||||
|
<${childTag}
|
||||||
|
.modelValue="${{ value: 'other', checked: false }}"
|
||||||
|
></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.modelValue).to.equal('female');
|
||||||
|
el.modelValue = 'other';
|
||||||
|
expect(el.formElements[2].checked).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expect child nodes to only fire one model-value-changed event per instance', async () => {
|
||||||
|
let counter = 0;
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag}
|
||||||
|
name="gender"
|
||||||
|
@model-value-changed=${() => {
|
||||||
|
counter += 1;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag}
|
||||||
|
.modelValue=${{ value: 'female', checked: true }}
|
||||||
|
></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
counter = 0; // reset after setup which may result in different results
|
||||||
|
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(counter).to.equal(1); // male becomes checked, female becomes unchecked
|
||||||
|
|
||||||
|
// not changed values trigger no event
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(counter).to.equal(1);
|
||||||
|
|
||||||
|
el.formElements[2].checked = true;
|
||||||
|
expect(counter).to.equal(2); // other becomes checked, male becomes unchecked
|
||||||
|
|
||||||
|
// not found values trigger no event
|
||||||
|
el.modelValue = 'foo';
|
||||||
|
expect(counter).to.equal(2);
|
||||||
|
|
||||||
|
el.modelValue = 'male';
|
||||||
|
expect(counter).to.equal(3); // male becomes checked, other becomes unchecked
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be required', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender" .validators=${[new Required()]}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag}
|
||||||
|
.choiceValue=${{ subObject: 'satisfies required' }}
|
||||||
|
></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
expect(el.hasFeedbackFor).to.include('error');
|
||||||
|
expect(el.validationStates.error).to.exist;
|
||||||
|
expect(el.validationStates.error.Required).to.exist;
|
||||||
|
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(el.hasFeedbackFor).not.to.include('error');
|
||||||
|
expect(el.validationStates.error).to.exist;
|
||||||
|
expect(el.validationStates.error.Required).to.not.exist;
|
||||||
|
|
||||||
|
el.formElements[1].checked = true;
|
||||||
|
expect(el.hasFeedbackFor).not.to.include('error');
|
||||||
|
expect(el.validationStates.error).to.exist;
|
||||||
|
expect(el.validationStates.error.Required).to.not.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns serialized value', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(el.serializedValue).to.deep.equal('male');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns serialized value on unchecked state', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.serializedValue).to.deep.equal('');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multipleChoice', () => {
|
||||||
|
it('has a single modelValue representing all currently checked values', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['female']);
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(el.modelValue).to.eql(['male', 'female']);
|
||||||
|
el.formElements[2].checked = true;
|
||||||
|
expect(el.modelValue).to.eql(['male', 'female', 'other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a single serializedValue representing all currently checked values', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.serializedValue).to.eql(['female']);
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(el.serializedValue).to.eql(['male', 'female']);
|
||||||
|
el.formElements[2].checked = true;
|
||||||
|
expect(el.serializedValue).to.eql(['male', 'female', 'other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a single formattedValue representing all currently checked values', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.formattedValue).to.eql(['female']);
|
||||||
|
el.formElements[0].checked = true;
|
||||||
|
expect(el.formattedValue).to.eql(['male', 'female']);
|
||||||
|
el.formElements[2].checked = true;
|
||||||
|
expect(el.formattedValue).to.eql(['male', 'female', 'other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can check multiple checkboxes by setting the modelValue', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
el.modelValue = ['male', 'other'];
|
||||||
|
expect(el.modelValue).to.eql(['male', 'other']);
|
||||||
|
expect(el.formElements[0].checked).to.be.true;
|
||||||
|
expect(el.formElements[2].checked).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unchecks non-matching checkboxes when setting the modelValue', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<${parentTag} multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'} checked></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['male', 'other']);
|
||||||
|
expect(el.formElements[0].checked).to.be.true;
|
||||||
|
expect(el.formElements[2].checked).to.be.true;
|
||||||
|
|
||||||
|
el.modelValue = ['female'];
|
||||||
|
expect(el.formElements[0].checked).to.be.false;
|
||||||
|
expect(el.formElements[1].checked).to.be.true;
|
||||||
|
expect(el.formElements[2].checked).to.be.false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration with a parent form/fieldset', () => {
|
||||||
|
it('will serialize all children with their serializedValue', async () => {
|
||||||
|
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
||||||
|
<lion-fieldset>
|
||||||
|
<${parentTag} name="gender">
|
||||||
|
<${childTag} .choiceValue=${'male'} checked disabled></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'other'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
</lion-fieldset>
|
||||||
|
`));
|
||||||
|
|
||||||
|
expect(el.serializedValue).to.eql({
|
||||||
|
gender: 'female',
|
||||||
|
});
|
||||||
|
|
||||||
|
const choiceGroupEl = /** @type {ChoiceGroup} */ (el.querySelector('[name="gender"]'));
|
||||||
|
choiceGroupEl.multipleChoice = true;
|
||||||
|
expect(el.serializedValue).to.eql({
|
||||||
|
gender: ['female'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -104,22 +104,22 @@ describe('FormControlMixin', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not duplicate aria-describedby and aria-labelledby ids', async () => {
|
it('does not duplicate aria-describedby and aria-labelledby ids', async () => {
|
||||||
const lionField = /** @type {FormControlMixinClass} */ (await fixture(`
|
const el = /** @type {FormControlMixinClass} */ (await fixture(`
|
||||||
<${tagString} help-text="This element will be disconnected/reconnected">${inputSlot}</${tagString}>
|
<${tagString} help-text="This element will be disconnected/reconnected">${inputSlot}</${tagString}>
|
||||||
`));
|
`));
|
||||||
|
|
||||||
const wrapper = /** @type {LitElement} */ (await fixture(`<div></div>`));
|
const wrapper = /** @type {LitElement} */ (await fixture(`<div></div>`));
|
||||||
lionField.parentElement?.appendChild(wrapper);
|
el.parentElement?.appendChild(wrapper);
|
||||||
wrapper.appendChild(lionField);
|
wrapper.appendChild(el);
|
||||||
await wrapper.updateComplete;
|
await wrapper.updateComplete;
|
||||||
|
|
||||||
['aria-describedby', 'aria-labelledby'].forEach(ariaAttributeName => {
|
['aria-describedby', 'aria-labelledby'].forEach(ariaAttributeName => {
|
||||||
const ariaAttribute = Array.from(lionField.children)
|
const ariaAttribute = Array.from(el.children)
|
||||||
.find(child => child.slot === 'input')
|
.find(child => child.slot === 'input')
|
||||||
?.getAttribute(ariaAttributeName)
|
?.getAttribute(ariaAttributeName)
|
||||||
?.trim()
|
?.trim()
|
||||||
.split(' ');
|
.split(' ');
|
||||||
const hasDuplicate = !!ariaAttribute?.find((el, i) => ariaAttribute.indexOf(el) !== i);
|
const hasDuplicate = !!ariaAttribute?.find((elm, i) => ariaAttribute.indexOf(elm) !== i);
|
||||||
expect(hasDuplicate).to.be.false;
|
expect(hasDuplicate).to.be.false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -186,20 +186,32 @@ describe('FormControlMixin', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds aria-live="polite" to the feedback slot', async () => {
|
it('adds aria-live="polite" to the feedback slot', async () => {
|
||||||
const lionField = await fixture(html`
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
<${tag}>
|
<${tag}>
|
||||||
${inputSlot}
|
${inputSlot}
|
||||||
<div slot="feedback">Added to see attributes</div>
|
<div slot="feedback">Added to see attributes</div>
|
||||||
</${tag}>
|
</${tag}>
|
||||||
`);
|
`));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Array.from(lionField.children)
|
Array.from(el.children)
|
||||||
.find(child => child.slot === 'feedback')
|
.find(child => child.slot === 'feedback')
|
||||||
?.getAttribute('aria-live'),
|
?.getAttribute('aria-live'),
|
||||||
).to.equal('polite');
|
).to.equal('polite');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clicking the label should call `_onLabelClick`', async () => {
|
||||||
|
const spy = sinon.spy();
|
||||||
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
|
<${tag} ._onLabelClick="${spy}">
|
||||||
|
${inputSlot}
|
||||||
|
</${tag}>
|
||||||
|
`));
|
||||||
|
expect(spy).to.not.have.been.called;
|
||||||
|
el._labelNode.click();
|
||||||
|
expect(spy).to.have.been.calledOnce;
|
||||||
|
});
|
||||||
|
|
||||||
describe('Model-value-changed event propagation', () => {
|
describe('Model-value-changed event propagation', () => {
|
||||||
// @ts-expect-error base constructor same return type
|
// @ts-expect-error base constructor same return type
|
||||||
const FormControlWithRegistrarMixinClass = class extends FormControlMixin(
|
const FormControlWithRegistrarMixinClass = class extends FormControlMixin(
|
||||||
|
|
|
||||||
|
|
@ -1,394 +1,3 @@
|
||||||
import { html, LitElement } from '@lion/core';
|
import { runChoiceGroupMixinSuite } from '../../test-suites/choice-group/ChoiceGroupMixin.suite.js';
|
||||||
import { LionInput } from '@lion/input';
|
|
||||||
import '@lion/fieldset/lion-fieldset.js';
|
|
||||||
import { FormGroupMixin, Required } from '@lion/form-core';
|
|
||||||
import { expect, fixture } from '@open-wc/testing';
|
|
||||||
import { ChoiceGroupMixin } from '../../src/choice-group/ChoiceGroupMixin.js';
|
|
||||||
import { ChoiceInputMixin } from '../../src/choice-group/ChoiceInputMixin.js';
|
|
||||||
// import { LionField } from '../../src/LionField.js';
|
|
||||||
|
|
||||||
// class InputField extends LionField {
|
runChoiceGroupMixinSuite();
|
||||||
// get slots() {
|
|
||||||
// return {
|
|
||||||
// ...super.slots,
|
|
||||||
// input: () => document.createElement('input'),
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
describe('ChoiceGroupMixin', () => {
|
|
||||||
class ChoiceInput extends ChoiceInputMixin(LionInput) {}
|
|
||||||
customElements.define('choice-group-input', ChoiceInput);
|
|
||||||
// @ts-expect-error
|
|
||||||
class ChoiceGroup extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {}
|
|
||||||
customElements.define('choice-group', ChoiceGroup);
|
|
||||||
// @ts-expect-error
|
|
||||||
class ChoiceGroupMultiple extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.multipleChoice = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customElements.define('choice-group-multiple', ChoiceGroupMultiple);
|
|
||||||
|
|
||||||
it('has a single modelValue representing the currently checked radio value', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
expect(el.modelValue).to.equal('female');
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(el.modelValue).to.equal('male');
|
|
||||||
el.formElements[2].checked = true;
|
|
||||||
expect(el.modelValue).to.equal('other');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a single formattedValue representing the currently checked radio value', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
expect(el.formattedValue).to.equal('female');
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(el.formattedValue).to.equal('male');
|
|
||||||
el.formElements[2].checked = true;
|
|
||||||
expect(el.formattedValue).to.equal('other');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group-input .modelValue=${'Lara'}></choice-group-input>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
el.addFormElement(invalidChild);
|
|
||||||
}).to.throw(
|
|
||||||
'The choice-group name="gender" does not allow to register choice-group-input with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('automatically sets the name property of child radios to its own name', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.formElements[0].name).to.equal('gender');
|
|
||||||
expect(el.formElements[1].name).to.equal('gender');
|
|
||||||
|
|
||||||
const validChild = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
`));
|
|
||||||
el.appendChild(validChild);
|
|
||||||
|
|
||||||
expect(el.formElements[2].name).to.equal('gender');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if a child element with a different name than the group tries to register', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group-input name="foo" .choiceValue=${'male'}></choice-group-input>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
el.addFormElement(invalidChild);
|
|
||||||
}).to.throw(
|
|
||||||
'The choice-group name="gender" does not allow to register choice-group-input with custom names (name="foo" given)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can set initial modelValue on creation', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender" .modelValue=${'other'}>
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.modelValue).to.equal('other');
|
|
||||||
expect(el.formElements[2].checked).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can set initial serializedValue on creation', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender" .serializedValue=${'other'}>
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.serializedValue).to.equal('other');
|
|
||||||
expect(el.formElements[2].checked).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can set initial formattedValue on creation', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender" .formattedValue=${'other'}>
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.formattedValue).to.equal('other');
|
|
||||||
expect(el.formElements[2].checked).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can handle complex data via choiceValue', async () => {
|
|
||||||
const date = new Date(2018, 11, 24, 10, 33, 30, 0);
|
|
||||||
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="data">
|
|
||||||
<choice-group-input .choiceValue=${{ some: 'data' }}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${date} checked></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.modelValue).to.equal(date);
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(el.modelValue).to.deep.equal({ some: 'data' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can handle 0 and empty string as valid values', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="data">
|
|
||||||
<choice-group-input .choiceValue=${0} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${''}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.modelValue).to.equal(0);
|
|
||||||
el.formElements[1].checked = true;
|
|
||||||
expect(el.modelValue).to.equal('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can check a radio by supplying an available modelValue', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .modelValue="${{ value: 'male', checked: false }}"></choice-group-input>
|
|
||||||
<choice-group-input
|
|
||||||
.modelValue="${{ value: 'female', checked: true }}"
|
|
||||||
></choice-group-input>
|
|
||||||
<choice-group-input
|
|
||||||
.modelValue="${{ value: 'other', checked: false }}"
|
|
||||||
></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.modelValue).to.equal('female');
|
|
||||||
el.modelValue = 'other';
|
|
||||||
expect(el.formElements[2].checked).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('expect child nodes to only fire one model-value-changed event per instance', async () => {
|
|
||||||
let counter = 0;
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group
|
|
||||||
name="gender"
|
|
||||||
@model-value-changed=${() => {
|
|
||||||
counter += 1;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .modelValue=${{ value: 'female', checked: true }}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
counter = 0; // reset after setup which may result in different results
|
|
||||||
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(counter).to.equal(1); // male becomes checked, female becomes unchecked
|
|
||||||
|
|
||||||
// not changed values trigger no event
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(counter).to.equal(1);
|
|
||||||
|
|
||||||
el.formElements[2].checked = true;
|
|
||||||
expect(counter).to.equal(2); // other becomes checked, male becomes unchecked
|
|
||||||
|
|
||||||
// not found values trigger no event
|
|
||||||
el.modelValue = 'foo';
|
|
||||||
expect(counter).to.equal(2);
|
|
||||||
|
|
||||||
el.modelValue = 'male';
|
|
||||||
expect(counter).to.equal(3); // male becomes checked, other becomes unchecked
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can be required', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender" .validators=${[new Required()]}>
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input
|
|
||||||
.choiceValue=${{ subObject: 'satisfies required' }}
|
|
||||||
></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
expect(el.hasFeedbackFor).to.include('error');
|
|
||||||
expect(el.validationStates.error).to.exist;
|
|
||||||
expect(el.validationStates.error.Required).to.exist;
|
|
||||||
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(el.hasFeedbackFor).not.to.include('error');
|
|
||||||
expect(el.validationStates.error).to.exist;
|
|
||||||
expect(el.validationStates.error.Required).to.not.exist;
|
|
||||||
|
|
||||||
el.formElements[1].checked = true;
|
|
||||||
expect(el.hasFeedbackFor).not.to.include('error');
|
|
||||||
expect(el.validationStates.error).to.exist;
|
|
||||||
expect(el.validationStates.error.Required).to.not.exist;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns serialized value', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(el.serializedValue).to.deep.equal('male');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns serialized value on unchecked state', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.serializedValue).to.deep.equal('');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('multipleChoice', () => {
|
|
||||||
it('has a single modelValue representing all currently checked values', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group-multiple name="gender[]">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group-multiple>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.modelValue).to.eql(['female']);
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(el.modelValue).to.eql(['male', 'female']);
|
|
||||||
el.formElements[2].checked = true;
|
|
||||||
expect(el.modelValue).to.eql(['male', 'female', 'other']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a single serializedValue representing all currently checked values', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group-multiple name="gender[]">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group-multiple>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.serializedValue).to.eql(['female']);
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(el.serializedValue).to.eql(['male', 'female']);
|
|
||||||
el.formElements[2].checked = true;
|
|
||||||
expect(el.serializedValue).to.eql(['male', 'female', 'other']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a single formattedValue representing all currently checked values', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group-multiple name="gender[]">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group-multiple>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.formattedValue).to.eql(['female']);
|
|
||||||
el.formElements[0].checked = true;
|
|
||||||
expect(el.formattedValue).to.eql(['male', 'female']);
|
|
||||||
el.formElements[2].checked = true;
|
|
||||||
expect(el.formattedValue).to.eql(['male', 'female', 'other']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can check multiple checkboxes by setting the modelValue', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group-multiple name="gender[]">
|
|
||||||
<choice-group-input .choiceValue=${'male'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group-multiple>
|
|
||||||
`));
|
|
||||||
|
|
||||||
el.modelValue = ['male', 'other'];
|
|
||||||
expect(el.modelValue).to.eql(['male', 'other']);
|
|
||||||
expect(el.formElements[0].checked).to.be.true;
|
|
||||||
expect(el.formElements[2].checked).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unchecks non-matching checkboxes when setting the modelValue', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<choice-group-multiple name="gender[]">
|
|
||||||
<choice-group-input .choiceValue=${'male'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'}></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'} checked></choice-group-input>
|
|
||||||
</choice-group-multiple>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.modelValue).to.eql(['male', 'other']);
|
|
||||||
expect(el.formElements[0].checked).to.be.true;
|
|
||||||
expect(el.formElements[2].checked).to.be.true;
|
|
||||||
|
|
||||||
el.modelValue = ['female'];
|
|
||||||
expect(el.formElements[0].checked).to.be.false;
|
|
||||||
expect(el.formElements[1].checked).to.be.true;
|
|
||||||
expect(el.formElements[2].checked).to.be.false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Integration with a parent form/fieldset', () => {
|
|
||||||
it('will serialize all children with their serializedValue', async () => {
|
|
||||||
const el = /** @type {ChoiceGroup} */ (await fixture(html`
|
|
||||||
<lion-fieldset>
|
|
||||||
<choice-group name="gender">
|
|
||||||
<choice-group-input .choiceValue=${'male'} checked disabled></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
|
|
||||||
<choice-group-input .choiceValue=${'other'}></choice-group-input>
|
|
||||||
</choice-group>
|
|
||||||
</lion-fieldset>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(el.serializedValue).to.eql({
|
|
||||||
gender: 'female',
|
|
||||||
});
|
|
||||||
|
|
||||||
const choiceGroupEl = /** @type {ChoiceGroup} */ (el.querySelector('[name="gender"]'));
|
|
||||||
choiceGroupEl.multipleChoice = true;
|
|
||||||
expect(el.serializedValue).to.eql({
|
|
||||||
gender: ['female'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,6 @@ export class FormControlHost {
|
||||||
_enhanceLightDomA11y(): void;
|
_enhanceLightDomA11y(): void;
|
||||||
_enhanceLightDomA11yForAdditionalSlots(additionalSlots?: string[]): void;
|
_enhanceLightDomA11yForAdditionalSlots(additionalSlots?: string[]): void;
|
||||||
__reflectAriaAttr(attrName: string, nodes: HTMLElement[], reorder: boolean | undefined): void;
|
__reflectAriaAttr(attrName: string, nodes: HTMLElement[], reorder: boolean | undefined): void;
|
||||||
_onLabelChanged({ label }: { label: string }): void;
|
|
||||||
_onHelpTextChanged({ helpText }: { helpText: string }): void;
|
|
||||||
_isEmpty(modelValue?: unknown): boolean;
|
_isEmpty(modelValue?: unknown): boolean;
|
||||||
_getAriaDescriptionElements(): HTMLElement[];
|
_getAriaDescriptionElements(): HTMLElement[];
|
||||||
addToAriaLabelledBy(
|
addToAriaLabelledBy(
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ import '@lion/input/lion-input.js';
|
||||||
import '@lion/radio-group/lion-radio-group.js';
|
import '@lion/radio-group/lion-radio-group.js';
|
||||||
import '@lion/radio-group/lion-radio.js';
|
import '@lion/radio-group/lion-radio.js';
|
||||||
import '@lion/select/lion-select.js';
|
import '@lion/select/lion-select.js';
|
||||||
import '@lion/select-rich/lion-option.js';
|
import '@lion/listbox/lion-option.js';
|
||||||
import '@lion/select-rich/lion-options.js';
|
import '@lion/listbox/lion-options.js';
|
||||||
import '@lion/select-rich/lion-select-rich.js';
|
import '@lion/select-rich/lion-select-rich.js';
|
||||||
import '@lion/textarea/lion-textarea.js';
|
import '@lion/textarea/lion-textarea.js';
|
||||||
import { MinLength, Required } from '@lion/form-core';
|
import { MinLength, Required } from '@lion/form-core';
|
||||||
|
|
@ -126,9 +126,7 @@ export const main = () => {
|
||||||
</lion-checkbox-group>
|
</lion-checkbox-group>
|
||||||
<lion-input-stepper max="5" min="0" name="rsvp">
|
<lion-input-stepper max="5" min="0" name="rsvp">
|
||||||
<label slot="label">RSVP</label>
|
<label slot="label">RSVP</label>
|
||||||
<div slot="help-text">
|
<div slot="help-text">Max. 5 guests</div>
|
||||||
Max. 5 guests
|
|
||||||
</div>
|
|
||||||
</lion-input-stepper>
|
</lion-input-stepper>
|
||||||
<lion-textarea name="comments" label="Comments"></lion-textarea>
|
<lion-textarea name="comments" label="Comments"></lion-textarea>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
import { html } from 'lit-html';
|
import { html } from 'lit-html';
|
||||||
import '@lion/dialog/lion-dialog.js';
|
import '@lion/dialog/lion-dialog.js';
|
||||||
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';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Forms/System/Dialog integrations',
|
title: 'Forms/System/Dialog integrations',
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default {
|
||||||
export const main = () => html`
|
export const main = () => html`
|
||||||
<lion-listbox name="listbox" label="Default">
|
<lion-listbox name="listbox" label="Default">
|
||||||
<lion-option .choiceValue=${'Apple'}>Apple</lion-option>
|
<lion-option .choiceValue=${'Apple'}>Apple</lion-option>
|
||||||
<lion-option .choiceValue=${'Artichoke'}>Artichoke</lion-option>
|
<lion-option checked .choiceValue=${'Artichoke'}>Artichoke</lion-option>
|
||||||
<lion-option .choiceValue=${'Asparagus'}>Asparagus</lion-option>
|
<lion-option .choiceValue=${'Asparagus'}>Asparagus</lion-option>
|
||||||
<lion-option .choiceValue=${'Banana'}>Banana</lion-option>
|
<lion-option .choiceValue=${'Banana'}>Banana</lion-option>
|
||||||
<lion-option .choiceValue=${'Beets'}>Beets</lion-option>
|
<lion-option .choiceValue=${'Beets'}>Beets</lion-option>
|
||||||
|
|
@ -77,7 +77,7 @@ export const multiple = () => html`
|
||||||
|
|
||||||
When `orientation="horizontal"`, left and right arrow keys will be enabled, plus the screenreader
|
When `orientation="horizontal"`, left and right arrow keys will be enabled, plus the screenreader
|
||||||
will be informed about the direction of the options.
|
will be informed about the direction of the options.
|
||||||
By default, `orientation="horizontal"` is set, which enables up and down arrow keys.
|
By default, `orientation="vertical"` is set, which enables up and down arrow keys.
|
||||||
|
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const orientationHorizontal = () => html`
|
export const orientationHorizontal = () => html`
|
||||||
|
|
@ -146,7 +146,7 @@ export const selectionFollowsFocus = () => html`
|
||||||
|
|
||||||
## Rotate keyboard navigation
|
## Rotate keyboard navigation
|
||||||
|
|
||||||
Will give first option active state when navigated to the next option from last option.
|
`rotate-keyboard-navigation` attribute on the listbox will give the first option active state when navigated to the next option from last option.
|
||||||
|
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const rotateKeyboardNavigation = () => html`
|
export const rotateKeyboardNavigation = () => html`
|
||||||
|
|
@ -164,3 +164,24 @@ export const rotateKeyboardNavigation = () => html`
|
||||||
</lion-listbox>
|
</lion-listbox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Disabled options
|
||||||
|
|
||||||
|
Navigation will skip over disabled options. Let's disable Artichoke and Brussel sprout, because they're gross.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const disabledRotateNavigation = () => html`
|
||||||
|
<lion-listbox name="combo" label="Rotate with disabled options" rotate-keyboard-navigation>
|
||||||
|
<lion-option .choiceValue=${'Apple'}>Apple</lion-option>
|
||||||
|
<lion-option .choiceValue=${'Artichoke'} disabled>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=${'Brussel sprout'} disabled>Brussels sprout</lion-option>
|
||||||
|
<lion-option .choiceValue=${'Cabbage'}>Cabbage</lion-option>
|
||||||
|
<lion-option .choiceValue=${'Carrot'}>Carrot</lion-option>
|
||||||
|
</lion-listbox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
|
||||||
requestUpdateInternal(name, oldValue) {
|
requestUpdateInternal(name, oldValue) {
|
||||||
super.requestUpdateInternal(name, oldValue);
|
super.requestUpdateInternal(name, oldValue);
|
||||||
|
|
||||||
if (name === 'active') {
|
if (name === 'active' && this.active !== oldValue) {
|
||||||
this.dispatchEvent(new Event('active-changed', { bubbles: true }));
|
this.dispatchEvent(new Event('active-changed', { bubbles: true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ export class LionOptions extends FormRegistrarPortalMixin(LitElement) {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.role = 'listbox';
|
this.role = 'listbox';
|
||||||
// we made it a Lit-Element property because of this
|
|
||||||
// eslint-disable-next-line wc/no-constructor-attributes
|
|
||||||
this.tabIndex = 0;
|
this.tabIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/form-core';
|
import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/form-core';
|
||||||
import { css, html, dedupeMixin, ScopedElementsMixin, SlotMixin } from '@lion/core';
|
import { css, html, dedupeMixin, ScopedElementsMixin, SlotMixin } from '@lion/core';
|
||||||
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
||||||
|
import '@lion/core/src/closestPolyfill.js';
|
||||||
import { LionOptions } from './LionOptions.js';
|
import { LionOptions } from './LionOptions.js';
|
||||||
|
|
||||||
// TODO: extract ListNavigationWithActiveDescendantMixin that can be reused in [role="menu"]
|
// TODO: extract ListNavigationWithActiveDescendantMixin that can be reused in [role="menu"]
|
||||||
|
|
@ -92,6 +93,19 @@ const ListboxMixinImplementation = superclass =>
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override FormControlMixin
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line
|
||||||
|
_inputGroupInputTemplate() {
|
||||||
|
return html`
|
||||||
|
<div class="input-group__input">
|
||||||
|
<slot name="input"></slot>
|
||||||
|
<slot id="options-outlet"></slot>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
static get scopedElements() {
|
static get scopedElements() {
|
||||||
return {
|
return {
|
||||||
...super.scopedElements,
|
...super.scopedElements,
|
||||||
|
|
@ -99,6 +113,7 @@ const ListboxMixinImplementation = superclass =>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
get slots() {
|
get slots() {
|
||||||
return {
|
return {
|
||||||
...super.slots,
|
...super.slots,
|
||||||
|
|
@ -112,24 +127,86 @@ const ListboxMixinImplementation = superclass =>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure FormControlMixin
|
||||||
|
*/
|
||||||
|
get _inputNode() {
|
||||||
|
return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @type {LionOptions}
|
||||||
|
*/
|
||||||
get _listboxNode() {
|
get _listboxNode() {
|
||||||
return /** @type {LionOptions} */ (this._inputNode);
|
return /** @type {LionOptions} */ (this._inputNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @type {HTMLElement}
|
||||||
|
*/
|
||||||
get _listboxActiveDescendantNode() {
|
get _listboxActiveDescendantNode() {
|
||||||
return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`);
|
return /** @type {HTMLElement} */ (this._listboxNode.querySelector(
|
||||||
|
`#${this._listboxActiveDescendant}`,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @type {HTMLElement}
|
||||||
|
*/
|
||||||
|
get _listboxSlot() {
|
||||||
|
return /** @type {HTMLElement} */ (
|
||||||
|
/** @type {ShadowRoot} */ (this.shadowRoot).querySelector('slot[name=input]')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @type {HTMLElement}
|
||||||
|
*/
|
||||||
|
get _scrollTargetNode() {
|
||||||
|
return this._listboxNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @type {HTMLElement}
|
||||||
|
*/
|
||||||
|
get _activeDescendantOwnerNode() {
|
||||||
|
return this._listboxNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override ChoiceGroupMixin
|
||||||
|
*/
|
||||||
get serializedValue() {
|
get serializedValue() {
|
||||||
return this.modelValue;
|
return this.modelValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
|
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
|
||||||
// If you override one, gotta override the other, they go in pairs.
|
// If you override one, gotta override the other, they go in pairs.
|
||||||
|
/**
|
||||||
|
* @override ChoiceGroupMixin
|
||||||
|
*/
|
||||||
set serializedValue(value) {
|
set serializedValue(value) {
|
||||||
super.serializedValue = value;
|
super.serializedValue = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get activeIndex() {
|
||||||
|
return this.formElements.findIndex(el => el.active === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
set activeIndex(index) {
|
||||||
|
if (this.formElements[index]) {
|
||||||
|
const el = this.formElements[index];
|
||||||
|
this.__setChildActive(el);
|
||||||
|
} else {
|
||||||
|
this.__setChildActive(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {number | number[]}
|
* @type {number | number[]}
|
||||||
*/
|
*/
|
||||||
|
|
@ -151,47 +228,11 @@ const ListboxMixinImplementation = superclass =>
|
||||||
this.setCheckedIndex(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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
// this.disabled = false;
|
|
||||||
/**
|
/**
|
||||||
* When setting this to true, on initial render, no option will be selected.
|
* When setting this to true, on initial render, no option will be selected.
|
||||||
* It it advisable to override `_noSelectionTemplate` method in the select-invoker
|
* It is advisable to override `_noSelectionTemplate` method in the select-invoker
|
||||||
* to render some kind of placeholder initially
|
* to render some kind of placeholder initially
|
||||||
*/
|
*/
|
||||||
this.hasNoDefaultSelected = false;
|
this.hasNoDefaultSelected = false;
|
||||||
|
|
@ -219,6 +260,12 @@ const ListboxMixinImplementation = superclass =>
|
||||||
this.__hasInitialSelectedFormElement = false;
|
this.__hasInitialSelectedFormElement = false;
|
||||||
this._repropagationRole = 'choice-group'; // configures FormControlMixin
|
this._repropagationRole = 'choice-group'; // configures FormControlMixin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When listbox is coupled to a textbox (in case we are dealing with a combobox),
|
||||||
|
* spaces should not select an element (they need to be put in the textbox)
|
||||||
|
*/
|
||||||
|
this._listboxReceivesNoFocus = false;
|
||||||
|
|
||||||
/** @type {EventListener} */
|
/** @type {EventListener} */
|
||||||
this._listboxOnKeyDown = this._listboxOnKeyDown.bind(this);
|
this._listboxOnKeyDown = this._listboxOnKeyDown.bind(this);
|
||||||
/** @type {EventListener} */
|
/** @type {EventListener} */
|
||||||
|
|
@ -229,17 +276,20 @@ const ListboxMixinImplementation = superclass =>
|
||||||
this._onChildActiveChanged = this._onChildActiveChanged.bind(this);
|
this._onChildActiveChanged = this._onChildActiveChanged.bind(this);
|
||||||
/** @type {EventListener} */
|
/** @type {EventListener} */
|
||||||
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
|
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
if (this._listboxNode) {
|
if (this._listboxNode) {
|
||||||
// if there is none yet, it will be supplied via static get slots
|
// if there is none yet, it will be supplied via 'get slots'
|
||||||
this._listboxNode.registrationTarget = this;
|
this._listboxNode.registrationTarget = this;
|
||||||
}
|
}
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.__setupListboxNode();
|
this._setupListboxNode();
|
||||||
this.__setupEventListeners();
|
this.__setupEventListeners();
|
||||||
|
|
||||||
|
// TODO: should this be handled at a more generic level?
|
||||||
this.registrationComplete.then(() => {
|
this.registrationComplete.then(() => {
|
||||||
this.__initInteractionStates();
|
this.__initInteractionStates();
|
||||||
});
|
});
|
||||||
|
|
@ -250,23 +300,25 @@ const ListboxMixinImplementation = superclass =>
|
||||||
*/
|
*/
|
||||||
firstUpdated(changedProperties) {
|
firstUpdated(changedProperties) {
|
||||||
super.firstUpdated(changedProperties);
|
super.firstUpdated(changedProperties);
|
||||||
|
|
||||||
this.__moveOptionsToListboxNode();
|
this.__moveOptionsToListboxNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves options put in regulat slot to slot wiht role=listbox
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
*/
|
*/
|
||||||
__moveOptionsToListboxNode() {
|
updated(changedProperties) {
|
||||||
const slot = /** @type {HTMLSlotElement} */ (
|
super.updated(changedProperties);
|
||||||
/** @type {ShadowRoot} */ (this.shadowRoot).getElementById('options-outlet')
|
|
||||||
);
|
if (this.formElements.length === 1) {
|
||||||
if (slot) {
|
this.singleOption = true;
|
||||||
slot.addEventListener('slotchange', () => {
|
}
|
||||||
slot.assignedNodes().forEach(node => {
|
|
||||||
this._listboxNode.appendChild(node);
|
if (changedProperties.has('disabled')) {
|
||||||
});
|
if (this.disabled) {
|
||||||
});
|
this.__requestOptionsToBeDisabled();
|
||||||
|
} else {
|
||||||
|
this.__retractRequestOptionsToBeDisabled();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,63 +330,28 @@ const ListboxMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In the select disabled options are still going to a possible value for example
|
* When `multipleChoice` is false, will toggle, else will check provided index
|
||||||
* when prefilling or programmatically setting it.
|
* @param {number} index
|
||||||
*
|
* @param {'set'|'unset'|'toggle'} multiMode
|
||||||
* @override
|
|
||||||
*/
|
*/
|
||||||
_getCheckedElements() {
|
setCheckedIndex(index, multiMode = 'toggle') {
|
||||||
return this.formElements.filter(el => el.checked);
|
if (this.formElements[index]) {
|
||||||
}
|
if (!this.multipleChoice) {
|
||||||
|
this.formElements[index].checked = true;
|
||||||
__initInteractionStates() {
|
// In __onChildCheckedChanged, which also responds to programmatic (model)value changes
|
||||||
this.initInteractionState();
|
// of children, we do the rest (uncheck siblings)
|
||||||
}
|
} else if (multiMode === 'toggle') {
|
||||||
|
this.formElements[index].checked = !this.formElements[index].checked;
|
||||||
// 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 {
|
} else {
|
||||||
// this._invokerNode.retractRequestToBeDisabled();
|
this.formElements[index].checked = multiMode === 'set';
|
||||||
this.__retractRequestOptionsToBeDisabled();
|
|
||||||
}
|
}
|
||||||
|
} else if (!this.multipleChoice) {
|
||||||
|
this._uncheckChildren();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @override
|
* @enhance FormRegistrarMixin: make sure children have specific default states when added
|
||||||
*/
|
|
||||||
// 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 {LionOption} child
|
||||||
* @param {Number} indexToInsertAt
|
* @param {Number} indexToInsertAt
|
||||||
*/
|
*/
|
||||||
|
|
@ -342,7 +359,6 @@ const ListboxMixinImplementation = superclass =>
|
||||||
addFormElement(child, indexToInsertAt) {
|
addFormElement(child, indexToInsertAt) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
super.addFormElement(/** @type {FormControl} */ child, indexToInsertAt);
|
super.addFormElement(/** @type {FormControl} */ child, indexToInsertAt);
|
||||||
|
|
||||||
// we need to adjust the elements being registered
|
// we need to adjust the elements being registered
|
||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
child.id = child.id || `${this.localName}-option-${uuid()}`;
|
child.id = child.id || `${this.localName}-option-${uuid()}`;
|
||||||
|
|
@ -351,17 +367,6 @@ const ListboxMixinImplementation = superclass =>
|
||||||
child.makeRequestToBeDisabled();
|
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,
|
// TODO: small perf improvement could be made if logic below would be scheduled to next update,
|
||||||
// so it occurs once for all options
|
// so it occurs once for all options
|
||||||
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
|
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
|
||||||
|
|
@ -376,6 +381,183 @@ const ListboxMixinImplementation = superclass =>
|
||||||
/* eslint-enable no-param-reassign */
|
/* eslint-enable no-param-reassign */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override ChoiceGroupMixin: in the select disabled options are still going to a possible
|
||||||
|
* value, for example when prefilling or programmatically setting it.
|
||||||
|
*/
|
||||||
|
_getCheckedElements() {
|
||||||
|
return this.formElements.filter(el => el.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupListboxNode() {
|
||||||
|
if (this._listboxNode) {
|
||||||
|
this.__setupListboxNodeInteractions();
|
||||||
|
} else if (this._listboxSlot) {
|
||||||
|
/**
|
||||||
|
* For ShadyDom the listboxNode is available right from the start so we can add those events
|
||||||
|
* immediately.
|
||||||
|
* For native ShadowDom the select gets rendered before the listboxNode is available so we
|
||||||
|
* will add an event to the slotchange and add the events once available.
|
||||||
|
*/
|
||||||
|
this._listboxSlot.addEventListener('slotchange', () => {
|
||||||
|
this.__setupListboxNodeInteractions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_teardownListboxNode() {
|
||||||
|
if (this._listboxNode) {
|
||||||
|
this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
|
||||||
|
this._listboxNode.removeEventListener('click', this._listboxOnClick);
|
||||||
|
this._listboxNode.removeEventListener('keyup', this._listboxOnKeyUp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} currentIndex
|
||||||
|
* @param {number} [offset=1]
|
||||||
|
*/
|
||||||
|
_getNextEnabledOption(currentIndex, offset = 1) {
|
||||||
|
return this.__getEnabledOption(currentIndex, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} currentIndex
|
||||||
|
* @param {number} [offset=-1]
|
||||||
|
*/
|
||||||
|
_getPreviousEnabledOption(currentIndex, offset = -1) {
|
||||||
|
return this.__getEnabledOption(currentIndex, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
* @param {Event & { target: LionOption }} ev
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars, class-methods-use-this
|
||||||
|
_onChildActiveChanged({ target }) {
|
||||||
|
if (target.active === true) {
|
||||||
|
this.__setChildActive(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 ' ':
|
||||||
|
case 'Enter': {
|
||||||
|
if (key === ' ' && this._listboxReceivesNoFocus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!this.formElements[this.activeIndex]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.formElements[this.activeIndex].disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setCheckedIndex(this.activeIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp':
|
||||||
|
ev.preventDefault();
|
||||||
|
if (this.orientation === 'vertical') {
|
||||||
|
this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
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':
|
||||||
|
if (this.orientation === 'horizontal') {
|
||||||
|
this.activeIndex = this._getNextEnabledOption(this.activeIndex);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
if (this._listboxReceivesNoFocus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
this.activeIndex = this._getNextEnabledOption(0, 0);
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
if (this._listboxReceivesNoFocus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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.activeIndex = foundIndex;
|
||||||
|
this.setCheckedIndex(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 'Enter':
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure FormControlMixin
|
||||||
|
*/
|
||||||
|
_onLabelClick() {
|
||||||
|
this._listboxNode.focus();
|
||||||
|
}
|
||||||
|
|
||||||
__setupEventListeners() {
|
__setupEventListeners() {
|
||||||
this._listboxNode.addEventListener(
|
this._listboxNode.addEventListener(
|
||||||
'active-changed',
|
'active-changed',
|
||||||
|
|
@ -385,8 +567,6 @@ const ListboxMixinImplementation = superclass =>
|
||||||
'model-value-changed',
|
'model-value-changed',
|
||||||
/** @type {EventListener} */ (this.__proxyChildModelValueChanged),
|
/** @type {EventListener} */ (this.__proxyChildModelValueChanged),
|
||||||
);
|
);
|
||||||
|
|
||||||
// this._listboxNode.addEventListener('checked-changed', this.__onChildCheckedChanged);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
__teardownEventListeners() {
|
__teardownEventListeners() {
|
||||||
|
|
@ -401,18 +581,34 @@ const ListboxMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Event & { target: LionOption }} ev
|
* @param {LionOption | null} el
|
||||||
*/
|
*/
|
||||||
_onChildActiveChanged({ target }) {
|
__setChildActive(el) {
|
||||||
if (target.active === true) {
|
|
||||||
this.formElements.forEach(formElement => {
|
this.formElements.forEach(formElement => {
|
||||||
if (formElement !== target) {
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
formElement.active = false;
|
formElement.active = el === formElement;
|
||||||
|
});
|
||||||
|
if (!el) {
|
||||||
|
this._activeDescendantOwnerNode.removeAttribute('aria-activedescendant');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._activeDescendantOwnerNode.setAttribute('aria-activedescendant', el.id);
|
||||||
|
if (!isInView(this._scrollTargetNode, el)) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {LionOption|LionOption[]} [exclude]
|
||||||
|
*/
|
||||||
|
_uncheckChildren(exclude = []) {
|
||||||
|
const excludes = Array.isArray(exclude) ? exclude : [exclude];
|
||||||
|
this.formElements.forEach(option => {
|
||||||
|
if (!excludes.includes(option)) {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
option.checked = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this._listboxNode.setAttribute('aria-activedescendant', target.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -423,15 +619,8 @@ const ListboxMixinImplementation = superclass =>
|
||||||
if (cfgOrEvent.stopPropagation) {
|
if (cfgOrEvent.stopPropagation) {
|
||||||
cfgOrEvent.stopPropagation();
|
cfgOrEvent.stopPropagation();
|
||||||
}
|
}
|
||||||
if (target.checked) {
|
if (target.checked && !this.multipleChoice) {
|
||||||
if (!this.multipleChoice) {
|
this._uncheckChildren(target);
|
||||||
this.formElements.forEach(formElement => {
|
|
||||||
if (formElement !== target) {
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
formElement.checked = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -467,196 +656,51 @@ const ListboxMixinImplementation = superclass =>
|
||||||
* @param {number} currentIndex
|
* @param {number} currentIndex
|
||||||
* @param {number} offset
|
* @param {number} offset
|
||||||
*/
|
*/
|
||||||
__getNextOption(currentIndex, offset) {
|
__getEnabledOption(currentIndex, offset) {
|
||||||
/**
|
/**
|
||||||
* @param {number} i
|
* @param {number} i
|
||||||
*/
|
*/
|
||||||
const until = i => (offset === 1 ? i < this.formElements.length : i >= 0);
|
const until = i => (offset === 1 ? i < this.formElements.length : i >= 0);
|
||||||
|
|
||||||
|
// Try to find the next / previous option
|
||||||
for (let i = currentIndex + offset; until(i); i += offset) {
|
for (let i = currentIndex + offset; until(i); i += offset) {
|
||||||
if (this.formElements[i] && !this.formElements[i].disabled) {
|
if (this.formElements[i] && !this.formElements[i].hasAttribute('aria-hidden')) {
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If above was unsuccessful, try to find the next/previous either
|
||||||
|
// from end --> start or start --> end
|
||||||
if (this.rotateKeyboardNavigation) {
|
if (this.rotateKeyboardNavigation) {
|
||||||
const startIndex = offset === -1 ? this.formElements.length - 1 : 0;
|
const startIndex = offset === -1 ? this.formElements.length - 1 : 0;
|
||||||
for (let i = startIndex; until(i); i += 1) {
|
for (let i = startIndex; until(i); i += offset) {
|
||||||
if (this.formElements[i] && !this.formElements[i].disabled) {
|
if (this.formElements[i] && !this.formElements[i].hasAttribute('aria-hidden')) {
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If above was unsuccessful, return currentIndex that we started with
|
||||||
return currentIndex;
|
return currentIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} currentIndex
|
* Moves options put in unnamed slot to slot with [role="listbox"]
|
||||||
* @param {number} [offset=1]
|
|
||||||
*/
|
*/
|
||||||
_getNextEnabledOption(currentIndex, offset = 1) {
|
__moveOptionsToListboxNode() {
|
||||||
return this.__getNextOption(currentIndex, offset);
|
const slot = /** @type {HTMLSlotElement} */ (
|
||||||
}
|
/** @type {ShadowRoot} */ (this.shadowRoot).getElementById('options-outlet')
|
||||||
|
|
||||||
/**
|
|
||||||
* @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', () => {
|
if (slot) {
|
||||||
this._setupListboxNodeInteractions();
|
slot.assignedNodes().forEach(node => {
|
||||||
|
this._listboxNode.appendChild(node);
|
||||||
|
});
|
||||||
|
slot.addEventListener('slotchange', () => {
|
||||||
|
slot.assignedNodes().forEach(node => {
|
||||||
|
this._listboxNode.appendChild(node);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -678,19 +722,40 @@ const ListboxMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move to FormControl / ValidateMixin?
|
|
||||||
/**
|
/**
|
||||||
* @param {string} value
|
* Helper method used within `._setupListboxNode`
|
||||||
*/
|
*/
|
||||||
set fieldName(value) {
|
__setupListboxNodeInteractions() {
|
||||||
this.__fieldName = value;
|
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);
|
||||||
|
/** Since _scrollTargetNode can be _listboxNode, handle here */
|
||||||
|
this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
get fieldName() {
|
// TODO: move to ChoiceGroupMixin?
|
||||||
const label =
|
__requestOptionsToBeDisabled() {
|
||||||
this.label ||
|
this.formElements.forEach(el => {
|
||||||
(this.querySelector('[slot=label]') && this.querySelector('[slot=label]')?.textContent);
|
if (el.makeRequestToBeDisabled) {
|
||||||
return this.__fieldName || label || this.name;
|
el.makeRequestToBeDisabled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
__retractRequestOptionsToBeDisabled() {
|
||||||
|
this.formElements.forEach(el => {
|
||||||
|
if (el.retractRequestToBeDisabled) {
|
||||||
|
el.retractRequestToBeDisabled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
__initInteractionStates() {
|
||||||
|
this.initInteractionState();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -55,7 +55,7 @@ export declare class ListboxHost {
|
||||||
|
|
||||||
protected _listboxOnKeyUp(ev: KeyboardEvent): void;
|
protected _listboxOnKeyUp(ev: KeyboardEvent): void;
|
||||||
|
|
||||||
protected _setupListboxNodeInteractions(): void;
|
protected _setupListboxNode(): void;
|
||||||
|
|
||||||
protected _teardownListboxNode(): void;
|
protected _teardownListboxNode(): void;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ export class OverlayController extends EventTargetShim {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The element that is placed behin the contentNode. When not provided and `hasBackdrop` is true,
|
* The element that is placed behind the contentNode. When not provided and `hasBackdrop` is true,
|
||||||
* a backdropNode will be automatically created
|
* a backdropNode will be automatically created
|
||||||
* @type {HTMLElement}
|
* @type {HTMLElement}
|
||||||
*/
|
*/
|
||||||
|
|
@ -664,7 +664,8 @@ export class OverlayController extends EventTargetShim {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isShown) {
|
if (this.isShown) {
|
||||||
/** @type {function} */ (this._showResolve)();
|
/** @type {function} */
|
||||||
|
(this._showResolve)();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -680,7 +681,8 @@ export class OverlayController extends EventTargetShim {
|
||||||
this.dispatchEvent(new Event('show'));
|
this.dispatchEvent(new Event('show'));
|
||||||
await this.transitionShow({ backdropNode: this.backdropNode, contentNode: this.contentNode });
|
await this.transitionShow({ backdropNode: this.backdropNode, contentNode: this.contentNode });
|
||||||
}
|
}
|
||||||
/** @type {function} */ (this._showResolve)();
|
/** @type {function} */
|
||||||
|
(this._showResolve)();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,11 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
* @returns {OverlayController}
|
* @returns {OverlayController}
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
_defineOverlay({ contentNode, invokerNode, backdropNode, contentWrapperNode }) {
|
_defineOverlay({ contentNode, invokerNode, referenceNode, backdropNode, contentWrapperNode }) {
|
||||||
return new OverlayController({
|
return new OverlayController({
|
||||||
contentNode,
|
contentNode,
|
||||||
invokerNode,
|
invokerNode,
|
||||||
|
referenceNode,
|
||||||
backdropNode,
|
backdropNode,
|
||||||
contentWrapperNode,
|
contentWrapperNode,
|
||||||
...this._defineOverlayConfig(), // wc provided in the class as defaults
|
...this._defineOverlayConfig(), // wc provided in the class as defaults
|
||||||
|
|
@ -84,7 +85,7 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @overridable method `_defineOverlay`
|
* @overridable method `_defineOverlayConfig`
|
||||||
* @desc returns an object with default configuration options for your overlay component.
|
* @desc returns an object with default configuration options for your overlay component.
|
||||||
* This is generally speaking easier to override than _defineOverlay method entirely.
|
* This is generally speaking easier to override than _defineOverlay method entirely.
|
||||||
* @returns {OverlayConfig}
|
* @returns {OverlayConfig}
|
||||||
|
|
@ -97,7 +98,7 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ has: (arg0: string) => any; }} changedProperties
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
*/
|
*/
|
||||||
updated(changedProperties) {
|
updated(changedProperties) {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
@ -168,6 +169,14 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
return Array.from(this.children).find(child => child.slot === 'invoker');
|
return Array.from(this.children).find(child => child.slot === 'invoker');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overridable
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
get _overlayReferenceNode() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
get _overlayBackdropNode() {
|
get _overlayBackdropNode() {
|
||||||
return Array.from(this.children).find(child => child.slot === 'backdrop');
|
return Array.from(this.children).find(child => child.slot === 'backdrop');
|
||||||
}
|
}
|
||||||
|
|
@ -191,6 +200,7 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
contentNode: this._overlayContentNode,
|
contentNode: this._overlayContentNode,
|
||||||
contentWrapperNode: this._overlayContentWrapperNode,
|
contentWrapperNode: this._overlayContentWrapperNode,
|
||||||
invokerNode: this._overlayInvokerNode,
|
invokerNode: this._overlayInvokerNode,
|
||||||
|
referenceNode: this._overlayReferenceNode,
|
||||||
backdropNode: this._overlayBackdropNode,
|
backdropNode: this._overlayBackdropNode,
|
||||||
});
|
});
|
||||||
this.__syncToOverlayController();
|
this.__syncToOverlayController();
|
||||||
|
|
@ -270,7 +280,8 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
__teardownSyncFromOverlayController() {
|
__teardownSyncFromOverlayController() {
|
||||||
/** @type {OverlayController} */ (this._overlayCtrl).removeEventListener(
|
/** @type {OverlayController} */
|
||||||
|
(this._overlayCtrl).removeEventListener(
|
||||||
'show',
|
'show',
|
||||||
/** @type {EventListener} */ (this.__onOverlayCtrlShow),
|
/** @type {EventListener} */ (this.__onOverlayCtrlShow),
|
||||||
);
|
);
|
||||||
|
|
@ -290,9 +301,11 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
|
|
||||||
__syncToOverlayController() {
|
__syncToOverlayController() {
|
||||||
if (this.opened) {
|
if (this.opened) {
|
||||||
/** @type {OverlayController} */ (this._overlayCtrl).show();
|
/** @type {OverlayController} */
|
||||||
|
(this._overlayCtrl).show();
|
||||||
} else {
|
} else {
|
||||||
/** @type {OverlayController} */ (this._overlayCtrl).hide();
|
/** @type {OverlayController} */
|
||||||
|
(this._overlayCtrl).hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,9 @@ loadDefaultFeedbackMessages();
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const main = () => html`
|
export const main = () => html`
|
||||||
<lion-select-rich name="favoriteColor" label="Favorite color">
|
<lion-select-rich name="favoriteColor" label="Favorite color">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
||||||
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
|
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -78,7 +76,6 @@ The main feature of this rich select that makes it rich, is that your options ca
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const optionsWithHTML = () => html`
|
export const optionsWithHTML = () => html`
|
||||||
<lion-select-rich label="Favorite color" name="color">
|
<lion-select-rich label="Favorite color" name="color">
|
||||||
<lion-options slot="input" class="demo-listbox">
|
|
||||||
<lion-option .modelValue=${{ value: 'red', checked: false }}>
|
<lion-option .modelValue=${{ value: 'red', checked: false }}>
|
||||||
<p style="color: red;">I am red</p>
|
<p style="color: red;">I am red</p>
|
||||||
<p>and multi Line</p>
|
<p>and multi Line</p>
|
||||||
|
|
@ -91,7 +88,6 @@ export const optionsWithHTML = () => html`
|
||||||
<p style="color: teal;">I am teal</p>
|
<p style="color: teal;">I am teal</p>
|
||||||
<p>and multi Line</p>
|
<p>and multi Line</p>
|
||||||
</lion-option>
|
</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -108,7 +104,6 @@ export const manyOptionsWithScrolling = () => html`
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<lion-select-rich id="scrollSelectRich" label="Favorite color" name="color">
|
<lion-select-rich id="scrollSelectRich" label="Favorite color" name="color">
|
||||||
<lion-options slot="input" class="demo-listbox">
|
|
||||||
<lion-option .modelValue=${{ value: 'red', checked: false }}>
|
<lion-option .modelValue=${{ value: 'red', checked: false }}>
|
||||||
<p style="color: red;">I am red</p>
|
<p style="color: red;">I am red</p>
|
||||||
</lion-option>
|
</lion-option>
|
||||||
|
|
@ -124,7 +119,6 @@ export const manyOptionsWithScrolling = () => html`
|
||||||
<lion-option .modelValue=${{ value: 'blue', checked: false }}>
|
<lion-option .modelValue=${{ value: 'blue', checked: false }}>
|
||||||
<p style="color: blue;">I am blue</p>
|
<p style="color: blue;">I am blue</p>
|
||||||
</lion-option>
|
</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -139,11 +133,9 @@ The readonly attribute is delegated to the invoker for disabling opening the ove
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const readOnlyPrefilled = () => html`
|
export const readOnlyPrefilled = () => html`
|
||||||
<lion-select-rich label="Read-only select" readonly name="color">
|
<lion-select-rich label="Read-only select" readonly name="color">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
|
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
|
||||||
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
|
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
|
||||||
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
|
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -157,11 +149,9 @@ If you disable the entire select, the disabled attribute is also delegated to th
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const disabledSelect = () => html`
|
export const disabledSelect = () => html`
|
||||||
<lion-select-rich label="Disabled select" disabled name="color">
|
<lion-select-rich label="Disabled select" disabled name="color">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
|
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
|
||||||
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
|
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
|
||||||
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
|
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -169,13 +159,11 @@ export const disabledSelect = () => html`
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const disabledOption = () => html`
|
export const disabledOption = () => html`
|
||||||
<lion-select-rich label="Disabled options" name="color">
|
<lion-select-rich label="Disabled options" name="color">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'} disabled>Red</lion-option>
|
<lion-option .choiceValue=${'red'} disabled>Red</lion-option>
|
||||||
<lion-option .choiceValue=${'blue'}>Blue</lion-option>
|
<lion-option .choiceValue=${'blue'}>Blue</lion-option>
|
||||||
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
|
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
|
||||||
<lion-option .choiceValue=${'green'}>Green</lion-option>
|
<lion-option .choiceValue=${'green'}>Green</lion-option>
|
||||||
<lion-option .choiceValue=${'teal'} disabled>Teal</lion-option>
|
<lion-option .choiceValue=${'teal'} disabled>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -194,12 +182,10 @@ export const validation = () => {
|
||||||
label="Favorite color"
|
label="Favorite color"
|
||||||
.validators="${[new Required()]}"
|
.validators="${[new Required()]}"
|
||||||
>
|
>
|
||||||
<lion-options slot="input" class="demo-listbox">
|
|
||||||
<lion-option .choiceValue=${null}>select a color</lion-option>
|
<lion-option .choiceValue=${null}>select a color</lion-option>
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
||||||
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
|
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
@ -217,22 +203,20 @@ export const renderOptions = () => {
|
||||||
{ type: 'mastercard', label: 'Master Card', amount: 12000, active: true },
|
{ type: 'mastercard', label: 'Master Card', amount: 12000, active: true },
|
||||||
{ type: 'visacard', label: 'Visa Card', amount: 0, active: false },
|
{ type: 'visacard', label: 'Visa Card', amount: 0, active: false },
|
||||||
];
|
];
|
||||||
function showOutput() {
|
function showOutput(ev) {
|
||||||
document.getElementById('demoRenderOutput').innerHTML = JSON.stringify(
|
document.getElementById('demoRenderOutput').innerHTML = JSON.stringify(
|
||||||
this.checkedValue,
|
ev.target.modelValue,
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return html`
|
return html`
|
||||||
<lion-select-rich label="Credit Card" name="color" @select-model-value-changed=${showOutput}>
|
<lion-select-rich label="Credit Card" name="color" @model-value-changed=${showOutput}>
|
||||||
<lion-options slot="input">
|
|
||||||
${objs.map(
|
${objs.map(
|
||||||
obj => html`
|
obj => html`
|
||||||
<lion-option .modelValue=${{ value: obj, checked: false }}>${obj.label}</lion-option>
|
<lion-option .modelValue=${{ value: obj, checked: false }}>${obj.label}</lion-option>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
<p>Full value:</p>
|
<p>Full value:</p>
|
||||||
<pre id="demoRenderOutput"></pre>
|
<pre id="demoRenderOutput"></pre>
|
||||||
|
|
@ -250,18 +234,14 @@ This changes the keyboard interaction.
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const interactionMode = () => html`
|
export const interactionMode = () => html`
|
||||||
<lion-select-rich label="Mac mode" name="color" interaction-mode="mac">
|
<lion-select-rich label="Mac mode" name="color" interaction-mode="mac">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
|
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
|
||||||
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
|
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
|
||||||
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
|
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
<lion-select-rich label="Windows/Linux mode" name="color" interaction-mode="windows/linux">
|
<lion-select-rich label="Windows/Linux mode" name="color" interaction-mode="windows/linux">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
|
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
|
||||||
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
|
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
|
||||||
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
|
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -302,11 +282,9 @@ export const checkedIndexAndValue = () => html`
|
||||||
Console log checked index and value
|
Console log checked index and value
|
||||||
</button>
|
</button>
|
||||||
<lion-select-rich id="checkedRichSelect" name="favoriteColor" label="Favorite color">
|
<lion-select-rich id="checkedRichSelect" name="favoriteColor" label="Favorite color">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
||||||
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
|
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -331,11 +309,9 @@ Both methods work with the `Required` validator.
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const noDefaultSelection = () => html`
|
export const noDefaultSelection = () => html`
|
||||||
<lion-select-rich name="favoriteColor" label="Favorite color" has-no-default-selected>
|
<lion-select-rich name="favoriteColor" label="Favorite color" has-no-default-selected>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
||||||
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -350,9 +326,7 @@ If there is a single option rendered, then `singleOption` property is set to `tr
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const singleOption = () => html`
|
export const singleOption = () => html`
|
||||||
<lion-select-rich label="Single Option" name="color">
|
<lion-select-rich label="Single Option" name="color">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -416,7 +390,7 @@ You can use this `selectedElement` to then render the content to your own invoke
|
||||||
```html
|
```html
|
||||||
<lion-select-rich>
|
<lion-select-rich>
|
||||||
<my-invoker-button slot="invoker"></my-invoker-button>
|
<my-invoker-button slot="invoker"></my-invoker-button>
|
||||||
<lion-options slot="input"> ... </lion-options>
|
...
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { css, html } from '@lion/core';
|
||||||
/**
|
/**
|
||||||
* LionSelectInvoker: invoker button consuming a selected element
|
* 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 [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { LionListbox } from '@lion/listbox';
|
import { LionListbox } from '@lion/listbox';
|
||||||
import { html, ScopedElementsMixin, SlotMixin } from '@lion/core';
|
import { html, ScopedElementsMixin, SlotMixin, browserDetection } from '@lion/core';
|
||||||
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
|
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
|
||||||
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
||||||
import { LionSelectInvoker } from './LionSelectInvoker.js';
|
import { LionSelectInvoker } from './LionSelectInvoker.js';
|
||||||
|
|
@ -13,7 +13,7 @@ import { LionSelectInvoker } from './LionSelectInvoker.js';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function detectInteractionMode() {
|
function detectInteractionMode() {
|
||||||
if (navigator.appVersion.indexOf('Mac') !== -1) {
|
if (browserDetection.isMac) {
|
||||||
return 'mac';
|
return 'mac';
|
||||||
}
|
}
|
||||||
return 'windows/linux';
|
return 'windows/linux';
|
||||||
|
|
@ -22,7 +22,6 @@ function detectInteractionMode() {
|
||||||
/**
|
/**
|
||||||
* LionSelectRich: wraps the <lion-listbox> element
|
* LionSelectRich: wraps the <lion-listbox> element
|
||||||
*/
|
*/
|
||||||
// @ts-expect-error base constructors same return type
|
|
||||||
export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) {
|
export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) {
|
||||||
static get scopedElements() {
|
static get scopedElements() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -64,7 +63,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
}
|
}
|
||||||
|
|
||||||
get _scrollTargetNode() {
|
get _scrollTargetNode() {
|
||||||
// @ts-expect-error _scrollTargetNode not on type
|
|
||||||
return this._overlayContentNode._scrollTargetNode || this._overlayContentNode;
|
return this._overlayContentNode._scrollTargetNode || this._overlayContentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +98,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
this.__overlayOnShow = this.__overlayOnShow.bind(this);
|
this.__overlayOnShow = this.__overlayOnShow.bind(this);
|
||||||
this.__invokerOnClick = this.__invokerOnClick.bind(this);
|
this.__invokerOnClick = this.__invokerOnClick.bind(this);
|
||||||
this.__overlayBeforeShow = this.__overlayBeforeShow.bind(this);
|
this.__overlayBeforeShow = this.__overlayBeforeShow.bind(this);
|
||||||
this.__focusInvokerOnLabelClick = this.__focusInvokerOnLabelClick.bind(this);
|
|
||||||
this._listboxOnClick = this._listboxOnClick.bind(this);
|
this._listboxOnClick = this._listboxOnClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,18 +106,11 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
|
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
|
||||||
this.__setupInvokerNode();
|
this.__setupInvokerNode();
|
||||||
this.__toggleInvokerDisabled();
|
this.__toggleInvokerDisabled();
|
||||||
if (this._labelNode) {
|
|
||||||
this._labelNode.addEventListener('click', this.__focusInvokerOnLabelClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addEventListener('keyup', this.__onKeyUp);
|
this.addEventListener('keyup', this.__onKeyUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
if (this._labelNode) {
|
|
||||||
this._labelNode.removeEventListener('click', this.__focusInvokerOnLabelClick);
|
|
||||||
}
|
|
||||||
this.__teardownInvokerNode();
|
this.__teardownInvokerNode();
|
||||||
this.removeEventListener('keyup', this.__onKeyUp);
|
this.removeEventListener('keyup', this.__onKeyUp);
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +135,30 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides FormRegistrar adding to make sure children have specific default states when added
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
* @param {LionOption} child
|
||||||
|
* @param {Number} indexToInsertAt
|
||||||
|
*/
|
||||||
|
addFormElement(child, indexToInsertAt) {
|
||||||
|
super.addFormElement(child, indexToInsertAt);
|
||||||
|
// the first elements checked by default
|
||||||
|
if (
|
||||||
|
!this.hasNoDefaultSelected &&
|
||||||
|
!this.__hasInitialSelectedFormElement &&
|
||||||
|
(!child.disabled || this.disabled)
|
||||||
|
) {
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
child.active = true;
|
||||||
|
child.checked = true;
|
||||||
|
/* eslint-enable no-param-reassign */
|
||||||
|
this.__hasInitialSelectedFormElement = true;
|
||||||
|
}
|
||||||
|
this._onFormElementsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In the select disabled options are still going to a possible value for example
|
* In the select disabled options are still going to a possible value for example
|
||||||
* when prefilling or programmatically setting it.
|
* when prefilling or programmatically setting it.
|
||||||
|
|
@ -159,16 +173,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
this.initInteractionState();
|
this.initInteractionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @override
|
|
||||||
* @param {FormControl} child the child element (field)
|
|
||||||
* @param {number} indexToInsertAt index to insert the form element at
|
|
||||||
*/
|
|
||||||
addFormElement(child, indexToInsertAt) {
|
|
||||||
super.addFormElement(child, indexToInsertAt);
|
|
||||||
this._onFormElementsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FormRegisteringHost} child the child element (field)
|
* @param {FormRegisteringHost} child the child element (field)
|
||||||
*/
|
*/
|
||||||
|
|
@ -178,14 +182,8 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
}
|
}
|
||||||
|
|
||||||
_onFormElementsChanged() {
|
_onFormElementsChanged() {
|
||||||
if (this.formElements.length === 1 && this.singleOption === false) {
|
this.singleOption = this.formElements.length === 1;
|
||||||
this.singleOption = true;
|
this._invokerNode.singleOption = this.singleOption;
|
||||||
this._invokerNode.singleOption = true;
|
|
||||||
}
|
|
||||||
if (this.formElements.length !== 1 && this.singleOption === true) {
|
|
||||||
this.singleOption = false;
|
|
||||||
this._invokerNode.singleOption = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -243,6 +241,7 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
<slot name="invoker"></slot>
|
<slot name="invoker"></slot>
|
||||||
<div id="overlay-content-node-wrapper">
|
<div id="overlay-content-node-wrapper">
|
||||||
<slot name="input"></slot>
|
<slot name="input"></slot>
|
||||||
|
<slot id="options-outlet"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -350,7 +349,10 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
|
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
|
||||||
}
|
}
|
||||||
|
|
||||||
__focusInvokerOnLabelClick() {
|
/**
|
||||||
|
* @configure FormControlMixin
|
||||||
|
*/
|
||||||
|
_onLabelClick() {
|
||||||
this._invokerNode.focus();
|
this._invokerNode.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -441,8 +443,8 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_setupListboxNodeInteractions() {
|
_setupListboxNode() {
|
||||||
super._setupListboxNodeInteractions();
|
super._setupListboxNode();
|
||||||
this._listboxNode.addEventListener('click', this._listboxOnClick);
|
this._listboxNode.addEventListener('click', this._listboxOnClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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 { browserDetection } from '@lion/core';
|
||||||
|
|
||||||
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
import '@lion/core/src/differentKeyEventNamesShimIE.js';
|
||||||
import '@lion/listbox/lion-option.js';
|
import '@lion/listbox/lion-option.js';
|
||||||
|
|
@ -7,66 +8,56 @@ import '@lion/listbox/lion-options.js';
|
||||||
import '../lion-select-rich.js';
|
import '../lion-select-rich.js';
|
||||||
|
|
||||||
describe('lion-select-rich interactions', () => {
|
describe('lion-select-rich interactions', () => {
|
||||||
describe('Keyboard navigation', () => {
|
describe('Interaction mode', () => {
|
||||||
it('navigates to first and last option with [Home] and [End] keys', async () => {
|
it('autodetects interactionMode if not defined', async () => {
|
||||||
|
const originalIsMac = browserDetection.isMac;
|
||||||
|
|
||||||
|
browserDetection.isMac = true;
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich opened interaction-mode="windows/linux">
|
<lion-select-rich><lion-option .choiceValue=${10}>Item 1</lion-option></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} checked>Item 3</lion-option>
|
|
||||||
<lion-option .choiceValue=${40}>Item 4</lion-option>
|
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
`);
|
||||||
expect(el.modelValue).to.equal(30);
|
expect(el.interactionMode).to.equal('mac');
|
||||||
|
const el2 = await fixture(html`
|
||||||
|
<lion-select-rich interaction-mode="windows/linux"
|
||||||
|
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
|
||||||
|
>
|
||||||
|
`);
|
||||||
|
expect(el2.interactionMode).to.equal('windows/linux');
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
|
browserDetection.isMac = false;
|
||||||
expect(el.modelValue).to.equal(10);
|
const el3 = await fixture(html`
|
||||||
|
<lion-select-rich><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich>
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
|
`);
|
||||||
expect(el.modelValue).to.equal(40);
|
expect(el3.interactionMode).to.equal('windows/linux');
|
||||||
});
|
const el4 = await fixture(html`
|
||||||
|
<lion-select-rich interaction-mode="mac"
|
||||||
|
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
|
||||||
|
>
|
||||||
|
`);
|
||||||
|
expect(el4.interactionMode).to.equal('mac');
|
||||||
|
browserDetection.isMac = originalIsMac;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Keyboard navigation Windows', () => {
|
it('derives selectionFollowsFocus and navigateWithinInvoker from interactionMode', async () => {
|
||||||
it('navigates through list with [ArrowDown] [ArrowUp] keys activates and checks the option', async () => {
|
|
||||||
function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) {
|
|
||||||
options.forEach((option, i) => {
|
|
||||||
if (i === selectedIndex) {
|
|
||||||
expect(option.checked).to.be.true;
|
|
||||||
} else {
|
|
||||||
expect(option.checked).to.be.false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich opened interaction-mode="windows/linux">
|
<lion-select-rich interaction-mode="windows/linux"
|
||||||
<lion-options slot="input">
|
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
|
||||||
<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.selectionFollowsFocus).to.be.true;
|
||||||
|
expect(el.navigateWithinInvoker).to.be.true;
|
||||||
|
|
||||||
const options = Array.from(el.querySelectorAll('lion-option'));
|
const el2 = await fixture(html`
|
||||||
expect(el.activeIndex).to.equal(0);
|
<lion-select-rich interaction-mode="mac"
|
||||||
expect(el.checkedIndex).to.equal(0);
|
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
|
||||||
expectOnlyGivenOneOptionToBeChecked(options, 0);
|
>
|
||||||
|
`);
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
expect(el2.selectionFollowsFocus).to.be.false;
|
||||||
expect(el.activeIndex).to.equal(1);
|
expect(el2.navigateWithinInvoker).to.be.false;
|
||||||
expect(el.checkedIndex).to.equal(1);
|
});
|
||||||
expectOnlyGivenOneOptionToBeChecked(options, 1);
|
|
||||||
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
|
|
||||||
expect(el.activeIndex).to.equal(0);
|
|
||||||
expect(el.checkedIndex).to.equal(0);
|
|
||||||
expectOnlyGivenOneOptionToBeChecked(options, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Invoker Keyboard navigation Windows', () => {
|
||||||
it('navigates through list with [ArrowDown] [ArrowUp] keys checks the option while listbox unopened', async () => {
|
it('navigates through list with [ArrowDown] [ArrowUp] keys checks the option while listbox unopened', async () => {
|
||||||
function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) {
|
function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) {
|
||||||
options.forEach((option, i) => {
|
options.forEach((option, i) => {
|
||||||
|
|
@ -103,7 +94,7 @@ describe('lion-select-rich interactions', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Disabled', () => {
|
describe('Disabled', () => {
|
||||||
it('cannot be focused if disabled', async () => {
|
it('invoker cannot be focused if disabled', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich disabled>
|
<lion-select-rich disabled>
|
||||||
<lion-options slot="input"></lion-options>
|
<lion-options slot="input"></lion-options>
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,39 @@ import '../lion-select-rich.js';
|
||||||
|
|
||||||
describe('lion-select-rich', () => {
|
describe('lion-select-rich', () => {
|
||||||
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> `);
|
||||||
<lion-select-rich label="foo">
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(document.activeElement === document.body).to.be.true;
|
expect(document.activeElement === document.body).to.be.true;
|
||||||
el._labelNode.click();
|
el._labelNode.click();
|
||||||
expect(document.activeElement === el._invokerNode).to.be.true;
|
expect(document.activeElement === el._invokerNode).to.be.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Invoker', () => {
|
it('checks the first enabled option', async () => {
|
||||||
it('generates an lion-select-invoker if no invoker is provided', async () => {
|
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich>
|
<lion-select-rich>
|
||||||
<lion-options slot="input"></lion-options>
|
<lion-option .choiceValue=${'Red'}></lion-option>
|
||||||
|
<lion-option .choiceValue=${'Hotpink'}></lion-option>
|
||||||
|
<lion-option .choiceValue=${'Blue'}></lion-option>
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
|
expect(el.activeIndex).to.equal(0);
|
||||||
|
expect(el.checkedIndex).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still has a checked value while disabled', async () => {
|
||||||
|
const el = await fixture(html`
|
||||||
|
<lion-select-rich disabled>
|
||||||
|
<lion-option .choiceValue=${'Red'}>Red</lion-option>
|
||||||
|
<lion-option .choiceValue=${'Hotpink'}>Hotpink</lion-option>
|
||||||
|
<lion-option .choiceValue=${'Blue'}>Blue</lion-option>
|
||||||
|
</lion-select-rich>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(el.modelValue).to.equal('Red');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Invoker', () => {
|
||||||
|
it('generates an lion-select-invoker if no invoker is provided', async () => {
|
||||||
|
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
|
||||||
|
|
||||||
expect(el._invokerNode).to.exist;
|
expect(el._invokerNode).to.exist;
|
||||||
expect(el._invokerNode.tagName).to.include('LION-SELECT-INVOKER');
|
expect(el._invokerNode.tagName).to.include('LION-SELECT-INVOKER');
|
||||||
|
|
@ -43,10 +59,8 @@ describe('lion-select-rich', () => {
|
||||||
it('sets the first option as the selectedElement if no option is checked', async () => {
|
it('sets the first option as the selectedElement if no option is checked', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich name="foo">
|
<lion-select-rich name="foo">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
const options = Array.from(el.querySelectorAll('lion-option'));
|
const options = Array.from(el.querySelectorAll('lion-option'));
|
||||||
|
|
@ -56,10 +70,8 @@ describe('lion-select-rich', () => {
|
||||||
it('syncs the selected element to the invoker', async () => {
|
it('syncs the selected element to the invoker', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich name="foo">
|
<lion-select-rich name="foo">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
const options = el.querySelectorAll('lion-option');
|
const options = el.querySelectorAll('lion-option');
|
||||||
|
|
@ -73,34 +85,35 @@ describe('lion-select-rich', () => {
|
||||||
it('delegates readonly to the invoker', async () => {
|
it('delegates readonly to the invoker', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich readonly>
|
<lion-select-rich readonly>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
expect(el.hasAttribute('readonly')).to.be.true;
|
expect(el.hasAttribute('readonly')).to.be.true;
|
||||||
expect(el._invokerNode.hasAttribute('readonly')).to.be.true;
|
expect(el._invokerNode.hasAttribute('readonly')).to.be.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delegates singleOption to the invoker', async () => {
|
||||||
|
const el = await fixture(html`
|
||||||
|
<lion-select-rich>
|
||||||
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
|
</lion-select-rich>
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(el.singleOption).to.be.true;
|
||||||
|
expect(el._invokerNode.hasAttribute('single-option')).to.be.true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('overlay', () => {
|
describe('overlay', () => {
|
||||||
it('should be closed by default', async () => {
|
it('should be closed by default', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.opened).to.be.false;
|
expect(el.opened).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows/hides the listbox via opened attribute', async () => {
|
it('shows/hides the listbox via opened attribute', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el.opened = true;
|
el.opened = true;
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(el._overlayCtrl.isShown).to.be.true;
|
expect(el._overlayCtrl.isShown).to.be.true;
|
||||||
|
|
@ -111,11 +124,7 @@ describe('lion-select-rich', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('syncs opened state with overlay shown', async () => {
|
it('syncs opened state with overlay shown', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich .opened=${true}></lion-select-rich> `);
|
||||||
<lion-select-rich .opened=${true}>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
const outerEl = await fixture('<button>somewhere</button>');
|
const outerEl = await fixture('<button>somewhere</button>');
|
||||||
|
|
||||||
expect(el.opened).to.be.true;
|
expect(el.opened).to.be.true;
|
||||||
|
|
@ -127,11 +136,7 @@ describe('lion-select-rich', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('will focus the listbox on open and invoker on close', async () => {
|
it('will focus the listbox on open and invoker on close', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
await el._overlayCtrl.show();
|
await el._overlayCtrl.show();
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(document.activeElement === el._listboxNode).to.be.true;
|
expect(document.activeElement === el._listboxNode).to.be.true;
|
||||||
|
|
@ -146,10 +151,8 @@ describe('lion-select-rich', () => {
|
||||||
it('opens the listbox with checked option as active', async () => {
|
it('opens the listbox with checked option as active', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich>
|
<lion-select-rich>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
await el._overlayCtrl.show();
|
await el._overlayCtrl.show();
|
||||||
|
|
@ -163,27 +166,21 @@ describe('lion-select-rich', () => {
|
||||||
it('stays closed on click if it is disabled or readonly or has a single option', async () => {
|
it('stays closed on click if it is disabled or readonly or has a single option', async () => {
|
||||||
const elReadOnly = await fixture(html`
|
const elReadOnly = await fixture(html`
|
||||||
<lion-select-rich readonly>
|
<lion-select-rich readonly>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const elDisabled = await fixture(html`
|
const elDisabled = await fixture(html`
|
||||||
<lion-select-rich disabled>
|
<lion-select-rich disabled>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const elSingleoption = await fixture(html`
|
const elSingleoption = await fixture(html`
|
||||||
<lion-select-rich>
|
<lion-select-rich>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -203,11 +200,9 @@ describe('lion-select-rich', () => {
|
||||||
it('sets inheritsReferenceWidth to min by default', async () => {
|
it('sets inheritsReferenceWidth to min by default', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich name="favoriteColor" label="Favorite color">
|
<lion-select-rich name="favoriteColor" label="Favorite color">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
||||||
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -220,11 +215,9 @@ describe('lion-select-rich', () => {
|
||||||
it('should override the inheritsWidth prop when no default selected feature is used', async () => {
|
it('should override the inheritsWidth prop when no default selected feature is used', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich name="favoriteColor" label="Favorite color" has-no-default-selected>
|
<lion-select-rich name="favoriteColor" label="Favorite color" has-no-default-selected>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
<lion-option .choiceValue=${'red'}>Red</lion-option>
|
||||||
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -252,10 +245,8 @@ describe('lion-select-rich', () => {
|
||||||
it('should have singleOption only if there is exactly one option', async () => {
|
it('should have singleOption only if there is exactly one option', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich>
|
<lion-select-rich>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
expect(el.singleOption).to.be.false;
|
expect(el.singleOption).to.be.false;
|
||||||
|
|
@ -281,9 +272,7 @@ describe('lion-select-rich', () => {
|
||||||
describe('interaction-mode', () => {
|
describe('interaction-mode', () => {
|
||||||
it('allows to specify an interaction-mode which determines other behaviors', async () => {
|
it('allows to specify an interaction-mode which determines other behaviors', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich interaction-mode="mac">
|
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
`);
|
||||||
expect(el.interactionMode).to.equal('mac');
|
expect(el.interactionMode).to.equal('mac');
|
||||||
});
|
});
|
||||||
|
|
@ -291,43 +280,27 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
describe('Keyboard navigation', () => {
|
describe('Keyboard navigation', () => {
|
||||||
it('opens the listbox with [Enter] key via click handler', async () => {
|
it('opens the listbox with [Enter] key via click handler', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el._invokerNode.click();
|
el._invokerNode.click();
|
||||||
await aTimeout();
|
await aTimeout();
|
||||||
expect(el.opened).to.be.true;
|
expect(el.opened).to.be.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the listbox with [ ](Space) key via click handler', async () => {
|
it('opens the listbox with [ ](Space) key via click handler', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el._invokerNode.click();
|
el._invokerNode.click();
|
||||||
await aTimeout();
|
await aTimeout();
|
||||||
expect(el.opened).to.be.true;
|
expect(el.opened).to.be.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes the listbox with [Escape] key once opened', async () => {
|
it('closes the listbox with [Escape] key once opened', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich opened> </lion-select-rich> `);
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
expect(el.opened).to.be.false;
|
expect(el.opened).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes the listbox with [Tab] key once opened', async () => {
|
it('closes the listbox with [Tab] key once opened', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich opened> </lion-select-rich> `);
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
// tab can only be caught via keydown
|
// tab can only be caught via keydown
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
|
||||||
expect(el.opened).to.be.false;
|
expect(el.opened).to.be.false;
|
||||||
|
|
@ -336,11 +309,7 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
describe('Mouse navigation', () => {
|
describe('Mouse navigation', () => {
|
||||||
it('opens the listbox via click on invoker', async () => {
|
it('opens the listbox via click on invoker', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el.opened).to.be.false;
|
expect(el.opened).to.be.false;
|
||||||
el._invokerNode.click();
|
el._invokerNode.click();
|
||||||
await nextFrame(); // reflection of click takes some time
|
await nextFrame(); // reflection of click takes some time
|
||||||
|
|
@ -350,9 +319,7 @@ describe('lion-select-rich', () => {
|
||||||
it('closes the listbox when an option gets clicked', async () => {
|
it('closes the listbox when an option gets clicked', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich opened>
|
<lion-select-rich opened>
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
expect(el.opened).to.be.true;
|
expect(el.opened).to.be.true;
|
||||||
|
|
@ -363,11 +330,7 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
describe('Keyboard navigation Windows', () => {
|
describe('Keyboard navigation Windows', () => {
|
||||||
it('closes the listbox with [Enter] key once opened', async () => {
|
it('closes the listbox with [Enter] key once opened', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich opened> </lion-select-rich> `);
|
||||||
<lion-select-rich opened>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||||
expect(el.opened).to.be.false;
|
expect(el.opened).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
@ -377,10 +340,8 @@ describe('lion-select-rich', () => {
|
||||||
it('checks active item and closes the listbox with [Enter] key via click handler once opened', async () => {
|
it('checks active item and closes the listbox with [Enter] key via click handler once opened', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich opened interaction-mode="mac">
|
<lion-select-rich opened interaction-mode="mac">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -395,9 +356,7 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
it('opens the listbox with [ArrowUp] key', async () => {
|
it('opens the listbox with [ArrowUp] key', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich interaction-mode="mac">
|
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
`);
|
||||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
|
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
@ -406,9 +365,7 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
it('opens the listbox with [ArrowDown] key', async () => {
|
it('opens the listbox with [ArrowDown] key', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich interaction-mode="mac">
|
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
`);
|
||||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
@ -420,10 +377,8 @@ describe('lion-select-rich', () => {
|
||||||
it('has the right references to its inner elements', async () => {
|
it('has the right references to its inner elements', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-select-rich label="age">
|
<lion-select-rich label="age">
|
||||||
<lion-options slot="input">
|
|
||||||
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
<lion-option .choiceValue=${10}>Item 1</lion-option>
|
||||||
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
<lion-option .choiceValue=${20}>Item 2</lion-option>
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`);
|
`);
|
||||||
expect(el._invokerNode.getAttribute('aria-labelledby')).to.contain(el._labelNode.id);
|
expect(el._invokerNode.getAttribute('aria-labelledby')).to.contain(el._labelNode.id);
|
||||||
|
|
@ -435,11 +390,7 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
it('notifies when the listbox is expanded or not', async () => {
|
it('notifies when the listbox is expanded or not', async () => {
|
||||||
// smoke test for overlay functionality
|
// smoke test for overlay functionality
|
||||||
const el = await fixture(html`
|
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
|
||||||
<lion-select-rich>
|
|
||||||
<lion-options slot="input"></lion-options>
|
|
||||||
</lion-select-rich>
|
|
||||||
`);
|
|
||||||
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false');
|
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false');
|
||||||
el.opened = true;
|
el.opened = true;
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
@ -483,16 +434,13 @@ describe('lion-select-rich', () => {
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<lion-select-rich label="Favorite color" name="color">
|
<lion-select-rich label="Favorite color" name="color">
|
||||||
<lion-options slot="input">
|
|
||||||
${this.colorList.map(
|
${this.colorList.map(
|
||||||
colorObj => html`
|
colorObj => html`
|
||||||
<lion-option
|
<lion-option .modelValue=${{ value: colorObj.value, checked: colorObj.checked }}
|
||||||
.modelValue=${{ value: colorObj.value, checked: colorObj.checked }}
|
|
||||||
>${colorObj.label}</lion-option
|
>${colorObj.label}</lion-option
|
||||||
>
|
>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</lion-options>
|
|
||||||
</lion-select-rich>
|
</lion-select-rich>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -545,13 +493,13 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<${mySelectTag} label="Favorite color" name="color">
|
<${mySelectTag} label="Favorite color" name="color">
|
||||||
<lion-options slot="input">
|
|
||||||
${Array(2).map(
|
${Array(2).map(
|
||||||
(_, i) => html`
|
(_, i) => html`
|
||||||
<lion-option .modelValue="${{ value: i, checked: false }}">value ${i}</lion-option>
|
<lion-option .modelValue="${{ value: i, checked: false }}">value ${i}</lion-option>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</lion-options>
|
|
||||||
</${mySelectTag}>
|
</${mySelectTag}>
|
||||||
`);
|
`);
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
@ -584,11 +532,11 @@ describe('lion-select-rich', () => {
|
||||||
|
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<${selectTag} id="color" name="color" label="Favorite color" has-no-default-selected>
|
<${selectTag} 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=${'red'}>Red</lion-option>
|
||||||
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
|
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
|
||||||
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
|
||||||
</lion-options>
|
|
||||||
</${selectTag}>
|
</${selectTag}>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue