feat(combobox): new package combobox

Co-authored-by: Joren Broekema <Joren.Broekema@ing.com>
This commit is contained in:
Thijs Louisse 2020-09-30 12:05:36 +02:00
parent 278798636c
commit 01a798e59e
43 changed files with 4724 additions and 1762 deletions

View 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

View file

@ -11,17 +11,17 @@
z-index: unset;
}
.sbdocs.sbdocs-preview>div:first-child {
z-index: 1;
.sbdocs.sbdocs-preview > div:first-child {
z-index: unset;
}
.sbdocs.sbdocs-preview>div>div {
.sbdocs.sbdocs-preview > div > div {
overflow: initial;
z-index: unset;
}
.sbdocs.sbdocs-preview>div>div [scale='1'] {
z-index: 1;
.sbdocs.sbdocs-preview > div > div [scale='1'] {
z-index: unset;
transform: none;
}
</style>

View file

@ -42,6 +42,7 @@ The accessibility column indicates whether the functionality is accessible in it
| 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. | ✔️ |
| [combobox](https://lion-web-components.netlify.app/?path=/docs/forms-combobox-overview--main) | [![combobox](https://img.shields.io/npm/v/@lion/combobox.svg)](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) | [![form](https://img.shields.io/npm/v/@lion/form.svg)](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) | [![form-core](https://img.shields.io/npm/v/@lion/form-core.svg)](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) | [![form-integrations](https://img.shields.io/npm/v/@lion/form-integrations.svg)](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) | [![input-iban](https://img.shields.io/npm/v/@lion/input-iban.svg)](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) | [![input-range](https://img.shields.io/npm/v/@lion/input-range.svg)](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) | [![input-stepper](https://img.shields.io/npm/v/@lion/input-stepper.svg)](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) | [![listbox](https://img.shields.io/npm/v/@lion/listbox.svg)](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) | [![radio-group](https://img.shields.io/npm/v/@lion/radio-group.svg)](https://www.npmjs.com/package/@lion/radio-group) | Group of radios | ✔️ |
| [select](https://lion-web-components.netlify.app/?path=/docs/forms-select--main) | [![select](https://img.shields.io/npm/v/@lion/select.svg)](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) | [![select-rich](https://img.shields.io/npm/v/@lion/select-rich.svg)](https://www.npmjs.com/package/@lion/select-rich) | 'rich' version of the native dropdown element | [#243][i243] |

View file

@ -0,0 +1 @@

204
packages/combobox/README.md Normal file
View 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.

View 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>
`;
```

View 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);

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

View 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);

View 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);

View 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);

View 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);

View file

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

View file

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

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

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

View file

@ -0,0 +1,4 @@
import { runListboxMixinSuite } from '@lion/listbox/test-suites/ListboxMixin.suite.js';
import '../lion-combobox.js';
runListboxMixinSuite({ tagString: 'lion-combobox' });

File diff suppressed because it is too large Load diff

View file

@ -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 = {
isIE11,
isIE11: /Trident/.test(window.navigator.userAgent),
isChrome: checkChrome(),
isIOSChrome: checkChrome('ios'),
isChromium: checkChrome('chromium'),
isMac: navigator.appVersion.indexOf('Mac') !== -1,
};

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

View file

@ -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() {
return this.__getDirectSlotChild('input');
}
@ -230,13 +201,15 @@ const FormControlMixinImplementation = superclass =>
this._ariaLabelledNodes = [];
/** @type {HTMLElement[]} */
this._ariaDescribedNodes = [];
/** @type {'child' | 'choice-group' | 'fieldset'} */
/** @type {'child'|'choice-group'|'fieldset'} */
this._repropagationRole = 'child';
this._isRepropagationEndpoint = false;
this.addEventListener(
'model-value-changed',
/** @type {EventListenerOrEventListenerObject} */ (this.__repropagateChildrenValues),
);
/** @type {EventListener} */
this._onLabelClick = this._onLabelClick.bind(this);
}
connectedCallback() {
@ -244,6 +217,46 @@ const FormControlMixinImplementation = superclass =>
this._enhanceLightDomClasses();
this._enhanceLightDomA11y();
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() {
@ -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:
* <div class="form-field__group-one">
@ -820,6 +813,20 @@ const FormControlMixinImplementation = superclass =>
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);

View file

@ -253,7 +253,7 @@ const ChoiceGroupMixinImplementation = superclass =>
}
_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);
}

View file

@ -47,8 +47,7 @@ function mimicUserInput(formControl, newViewValue) {
export function runFormatMixinSuite(customConfig) {
const cfg = {
tagString: null,
modelValueType: String,
suffix: '',
childTagString: null,
...customConfig,
};

View file

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

View file

@ -104,22 +104,22 @@ describe('FormControlMixin', () => {
});
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}>
`));
const wrapper = /** @type {LitElement} */ (await fixture(`<div></div>`));
lionField.parentElement?.appendChild(wrapper);
wrapper.appendChild(lionField);
el.parentElement?.appendChild(wrapper);
wrapper.appendChild(el);
await wrapper.updateComplete;
['aria-describedby', 'aria-labelledby'].forEach(ariaAttributeName => {
const ariaAttribute = Array.from(lionField.children)
const ariaAttribute = Array.from(el.children)
.find(child => child.slot === 'input')
?.getAttribute(ariaAttributeName)
?.trim()
.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;
});
});
@ -186,20 +186,32 @@ describe('FormControlMixin', () => {
});
it('adds aria-live="polite" to the feedback slot', async () => {
const lionField = await fixture(html`
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}>
${inputSlot}
<div slot="feedback">Added to see attributes</div>
</${tag}>
`);
`));
expect(
Array.from(lionField.children)
Array.from(el.children)
.find(child => child.slot === 'feedback')
?.getAttribute('aria-live'),
).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', () => {
// @ts-expect-error base constructor same return type
const FormControlWithRegistrarMixinClass = class extends FormControlMixin(

View file

@ -1,394 +1,3 @@
import { html, LitElement } from '@lion/core';
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';
import { runChoiceGroupMixinSuite } from '../../test-suites/choice-group/ChoiceGroupMixin.suite.js';
// class InputField extends LionField {
// 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'],
});
});
});
});
runChoiceGroupMixinSuite();

View file

@ -96,8 +96,6 @@ export class FormControlHost {
_enhanceLightDomA11y(): void;
_enhanceLightDomA11yForAdditionalSlots(additionalSlots?: string[]): void;
__reflectAriaAttr(attrName: string, nodes: HTMLElement[], reorder: boolean | undefined): void;
_onLabelChanged({ label }: { label: string }): void;
_onHelpTextChanged({ helpText }: { helpText: string }): void;
_isEmpty(modelValue?: unknown): boolean;
_getAriaDescriptionElements(): HTMLElement[];
addToAriaLabelledBy(

View file

@ -22,8 +22,8 @@ import '@lion/input/lion-input.js';
import '@lion/radio-group/lion-radio-group.js';
import '@lion/radio-group/lion-radio.js';
import '@lion/select/lion-select.js';
import '@lion/select-rich/lion-option.js';
import '@lion/select-rich/lion-options.js';
import '@lion/listbox/lion-option.js';
import '@lion/listbox/lion-options.js';
import '@lion/select-rich/lion-select-rich.js';
import '@lion/textarea/lion-textarea.js';
import { MinLength, Required } from '@lion/form-core';
@ -126,9 +126,7 @@ export const main = () => {
</lion-checkbox-group>
<lion-input-stepper max="5" min="0" name="rsvp">
<label slot="label">RSVP</label>
<div slot="help-text">
Max. 5 guests
</div>
<div slot="help-text">Max. 5 guests</div>
</lion-input-stepper>
<lion-textarea name="comments" label="Comments"></lion-textarea>
<div class="buttons">

View file

@ -6,8 +6,8 @@
import { html } from 'lit-html';
import '@lion/dialog/lion-dialog.js';
import '@lion/select-rich/lion-select-rich.js';
import '@lion/select-rich/lion-options.js';
import '@lion/select-rich/lion-option.js';
import '@lion/listbox/lion-options.js';
import '@lion/listbox/lion-option.js';
export default {
title: 'Forms/System/Dialog integrations',

View file

@ -23,7 +23,7 @@ export default {
export const main = () => html`
<lion-listbox name="listbox" label="Default">
<lion-option .choiceValue=${'Apple'}>Apple</lion-option>
<lion-option .choiceValue=${'Artichoke'}>Artichoke</lion-option>
<lion-option checked .choiceValue=${'Artichoke'}>Artichoke</lion-option>
<lion-option .choiceValue=${'Asparagus'}>Asparagus</lion-option>
<lion-option .choiceValue=${'Banana'}>Banana</lion-option>
<lion-option .choiceValue=${'Beets'}>Beets</lion-option>
@ -77,7 +77,7 @@ export const multiple = () => html`
When `orientation="horizontal"`, left and right arrow keys will be enabled, plus the screenreader
will be informed about the direction of the options.
By default, `orientation="horizontal"` is set, which enables up and down arrow keys.
By default, `orientation="vertical"` is set, which enables up and down arrow keys.
```js preview-story
export const orientationHorizontal = () => html`
@ -146,7 +146,7 @@ export const selectionFollowsFocus = () => html`
## 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
export const rotateKeyboardNavigation = () => html`
@ -164,3 +164,24 @@ export const rotateKeyboardNavigation = () => html`
</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>
`;
```

View file

@ -78,7 +78,7 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
requestUpdateInternal(name, oldValue) {
super.requestUpdateInternal(name, oldValue);
if (name === 'active') {
if (name === 'active' && this.active !== oldValue) {
this.dispatchEvent(new Event('active-changed', { bubbles: true }));
}
}

View file

@ -22,8 +22,6 @@ export class LionOptions extends FormRegistrarPortalMixin(LitElement) {
constructor() {
super();
this.role = 'listbox';
// we made it a Lit-Element property because of this
// eslint-disable-next-line wc/no-constructor-attributes
this.tabIndex = 0;
}

View file

@ -1,6 +1,7 @@
import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/form-core';
import { css, html, dedupeMixin, ScopedElementsMixin, SlotMixin } from '@lion/core';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import '@lion/core/src/closestPolyfill.js';
import { LionOptions } from './LionOptions.js';
// 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() {
return {
...super.scopedElements,
@ -99,6 +113,7 @@ const ListboxMixinImplementation = superclass =>
};
}
// @ts-ignore
get slots() {
return {
...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() {
return /** @type {LionOptions} */ (this._inputNode);
}
/**
* @overridable
* @type {HTMLElement}
*/
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() {
return this.modelValue;
}
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
// If you override one, gotta override the other, they go in pairs.
/**
* @override ChoiceGroupMixin
*/
set 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[]}
*/
@ -151,47 +228,11 @@ const ListboxMixinImplementation = superclass =>
this.setCheckedIndex(index);
}
/**
* When `multipleChoice` is false, will toggle, else will check provided index
* @param {Number} index
*/
setCheckedIndex(index) {
if (this.formElements[index]) {
if (!this.multipleChoice) {
this.formElements[index].checked = true;
} else {
this.formElements[index].checked = !this.formElements[index].checked;
// __onChildCheckedChanged, which also responds to programmatic (model)value changes
// of children, will do the rest
}
}
}
get activeIndex() {
return this.formElements.findIndex(el => el.active === true);
}
get _scrollTargetNode() {
return this._listboxNode;
}
set activeIndex(index) {
if (this.formElements[index]) {
const el = this.formElements[index];
el.active = true;
if (!isInView(this._scrollTargetNode, el)) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
constructor() {
super();
// this.disabled = false;
/**
* When setting this to true, on initial render, no option will be selected.
* It it advisable to override `_noSelectionTemplate` method in the select-invoker
* It is advisable to override `_noSelectionTemplate` method in the select-invoker
* to render some kind of placeholder initially
*/
this.hasNoDefaultSelected = false;
@ -219,6 +260,12 @@ const ListboxMixinImplementation = superclass =>
this.__hasInitialSelectedFormElement = false;
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} */
this._listboxOnKeyDown = this._listboxOnKeyDown.bind(this);
/** @type {EventListener} */
@ -229,17 +276,20 @@ const ListboxMixinImplementation = superclass =>
this._onChildActiveChanged = this._onChildActiveChanged.bind(this);
/** @type {EventListener} */
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
/** @type {EventListener} */
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
}
connectedCallback() {
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;
}
super.connectedCallback();
this.__setupListboxNode();
this._setupListboxNode();
this.__setupEventListeners();
// TODO: should this be handled at a more generic level?
this.registrationComplete.then(() => {
this.__initInteractionStates();
});
@ -250,23 +300,25 @@ const ListboxMixinImplementation = superclass =>
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__moveOptionsToListboxNode();
}
/**
* Moves options put in regulat slot to slot wiht role=listbox
* @param {import('lit-element').PropertyValues } changedProperties
*/
__moveOptionsToListboxNode() {
const slot = /** @type {HTMLSlotElement} */ (
/** @type {ShadowRoot} */ (this.shadowRoot).getElementById('options-outlet')
);
if (slot) {
slot.addEventListener('slotchange', () => {
slot.assignedNodes().forEach(node => {
this._listboxNode.appendChild(node);
});
});
updated(changedProperties) {
super.updated(changedProperties);
if (this.formElements.length === 1) {
this.singleOption = true;
}
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 prefilling or programmatically setting it.
*
* @override
* When `multipleChoice` is false, will toggle, else will check provided index
* @param {number} index
* @param {'set'|'unset'|'toggle'} multiMode
*/
_getCheckedElements() {
return this.formElements.filter(el => el.checked);
}
__initInteractionStates() {
this.initInteractionState();
}
// TODO: inherit from FormControl ?
get _inputNode() {
return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (this.formElements.length === 1) {
this.singleOption = true;
// this._invokerNode.singleOption = true;
}
if (changedProperties.has('disabled')) {
if (this.disabled) {
// this._invokerNode.makeRequestToBeDisabled();
this.__requestOptionsToBeDisabled();
setCheckedIndex(index, multiMode = 'toggle') {
if (this.formElements[index]) {
if (!this.multipleChoice) {
this.formElements[index].checked = true;
// In __onChildCheckedChanged, which also responds to programmatic (model)value changes
// of children, we do the rest (uncheck siblings)
} else if (multiMode === 'toggle') {
this.formElements[index].checked = !this.formElements[index].checked;
} else {
// this._invokerNode.retractRequestToBeDisabled();
this.__retractRequestOptionsToBeDisabled();
this.formElements[index].checked = multiMode === 'set';
}
} else if (!this.multipleChoice) {
this._uncheckChildren();
}
}
/**
* @override
*/
// eslint-disable-next-line
_inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="input"></slot>
<slot id="options-outlet"></slot>
</div>
`;
}
/**
* Overrides FormRegistrar adding to make sure children have specific default states when added
*
* @override
* @enhance FormRegistrarMixin: make sure children have specific default states when added
* @param {LionOption} child
* @param {Number} indexToInsertAt
*/
@ -342,7 +359,6 @@ const ListboxMixinImplementation = superclass =>
addFormElement(child, indexToInsertAt) {
// @ts-expect-error
super.addFormElement(/** @type {FormControl} */ child, indexToInsertAt);
// we need to adjust the elements being registered
/* eslint-disable no-param-reassign */
child.id = child.id || `${this.localName}-option-${uuid()}`;
@ -351,17 +367,6 @@ const ListboxMixinImplementation = superclass =>
child.makeRequestToBeDisabled();
}
// the first elements checked by default
if (
!this.hasNoDefaultSelected &&
!this.__hasInitialSelectedFormElement &&
(!child.disabled || this.disabled)
) {
child.active = true;
child.checked = true;
this.__hasInitialSelectedFormElement = true;
}
// TODO: small perf improvement could be made if logic below would be scheduled to next update,
// so it occurs once for all options
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
@ -376,6 +381,183 @@ const ListboxMixinImplementation = superclass =>
/* 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() {
this._listboxNode.addEventListener(
'active-changed',
@ -385,8 +567,6 @@ const ListboxMixinImplementation = superclass =>
'model-value-changed',
/** @type {EventListener} */ (this.__proxyChildModelValueChanged),
);
// this._listboxNode.addEventListener('checked-changed', this.__onChildCheckedChanged);
}
__teardownEventListeners() {
@ -401,18 +581,34 @@ const ListboxMixinImplementation = superclass =>
}
/**
* @param {Event & { target: LionOption }} ev
* @param {LionOption | null} el
*/
_onChildActiveChanged({ target }) {
if (target.active === true) {
this.formElements.forEach(formElement => {
if (formElement !== target) {
// eslint-disable-next-line no-param-reassign
formElement.active = false;
}
});
this._listboxNode.setAttribute('aria-activedescendant', target.id);
__setChildActive(el) {
this.formElements.forEach(formElement => {
// eslint-disable-next-line no-param-reassign
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;
}
});
}
/**
@ -423,15 +619,8 @@ const ListboxMixinImplementation = superclass =>
if (cfgOrEvent.stopPropagation) {
cfgOrEvent.stopPropagation();
}
if (target.checked) {
if (!this.multipleChoice) {
this.formElements.forEach(formElement => {
if (formElement !== target) {
// eslint-disable-next-line no-param-reassign
formElement.checked = false;
}
});
}
if (target.checked && !this.multipleChoice) {
this._uncheckChildren(target);
}
}
@ -467,196 +656,51 @@ const ListboxMixinImplementation = superclass =>
* @param {number} currentIndex
* @param {number} offset
*/
__getNextOption(currentIndex, offset) {
__getEnabledOption(currentIndex, offset) {
/**
* @param {number} i
*/
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) {
if (this.formElements[i] && !this.formElements[i].disabled) {
if (this.formElements[i] && !this.formElements[i].hasAttribute('aria-hidden')) {
return i;
}
}
// If above was unsuccessful, try to find the next/previous either
// from end --> start or start --> end
if (this.rotateKeyboardNavigation) {
const startIndex = offset === -1 ? this.formElements.length - 1 : 0;
for (let i = startIndex; until(i); i += 1) {
if (this.formElements[i] && !this.formElements[i].disabled) {
for (let i = startIndex; until(i); i += offset) {
if (this.formElements[i] && !this.formElements[i].hasAttribute('aria-hidden')) {
return i;
}
}
}
// If above was unsuccessful, return currentIndex that we started with
return currentIndex;
}
/**
* @param {number} currentIndex
* @param {number} [offset=1]
* Moves options put in unnamed slot to slot with [role="listbox"]
*/
_getNextEnabledOption(currentIndex, offset = 1) {
return this.__getNextOption(currentIndex, offset);
}
__moveOptionsToListboxNode() {
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', () => {
this._setupListboxNodeInteractions();
if (slot) {
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) {
this.__fieldName = value;
__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);
/** Since _scrollTargetNode can be _listboxNode, handle here */
this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
}
get fieldName() {
const label =
this.label ||
(this.querySelector('[slot=label]') && this.querySelector('[slot=label]')?.textContent);
return this.__fieldName || label || this.name;
// TODO: move to ChoiceGroupMixin?
__requestOptionsToBeDisabled() {
this.formElements.forEach(el => {
if (el.makeRequestToBeDisabled) {
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

View file

@ -55,7 +55,7 @@ export declare class ListboxHost {
protected _listboxOnKeyUp(ev: KeyboardEvent): void;
protected _setupListboxNodeInteractions(): void;
protected _setupListboxNode(): void;
protected _teardownListboxNode(): void;

View file

@ -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
* @type {HTMLElement}
*/
@ -312,7 +312,7 @@ export class OverlayController extends EventTargetShim {
}
/**
* For non `isTooltip`:
* For non `isTooltip`:
* - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode
* - sets aria-controls on invokerNode
* - returns focus to invokerNode on hide
@ -664,7 +664,8 @@ export class OverlayController extends EventTargetShim {
}
if (this.isShown) {
/** @type {function} */ (this._showResolve)();
/** @type {function} */
(this._showResolve)();
return;
}
@ -680,7 +681,8 @@ export class OverlayController extends EventTargetShim {
this.dispatchEvent(new Event('show'));
await this.transitionShow({ backdropNode: this.backdropNode, contentNode: this.contentNode });
}
/** @type {function} */ (this._showResolve)();
/** @type {function} */
(this._showResolve)();
}
/**

View file

@ -62,10 +62,11 @@ export const OverlayMixinImplementation = superclass =>
* @returns {OverlayController}
*/
// eslint-disable-next-line
_defineOverlay({ contentNode, invokerNode, backdropNode, contentWrapperNode }) {
_defineOverlay({ contentNode, invokerNode, referenceNode, backdropNode, contentWrapperNode }) {
return new OverlayController({
contentNode,
invokerNode,
referenceNode,
backdropNode,
contentWrapperNode,
...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.
* This is generally speaking easier to override than _defineOverlay method entirely.
* @returns {OverlayConfig}
@ -97,7 +98,7 @@ export const OverlayMixinImplementation = superclass =>
}
/**
* @param {{ has: (arg0: string) => any; }} changedProperties
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
@ -168,6 +169,14 @@ export const OverlayMixinImplementation = superclass =>
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() {
return Array.from(this.children).find(child => child.slot === 'backdrop');
}
@ -191,6 +200,7 @@ export const OverlayMixinImplementation = superclass =>
contentNode: this._overlayContentNode,
contentWrapperNode: this._overlayContentWrapperNode,
invokerNode: this._overlayInvokerNode,
referenceNode: this._overlayReferenceNode,
backdropNode: this._overlayBackdropNode,
});
this.__syncToOverlayController();
@ -270,7 +280,8 @@ export const OverlayMixinImplementation = superclass =>
}
__teardownSyncFromOverlayController() {
/** @type {OverlayController} */ (this._overlayCtrl).removeEventListener(
/** @type {OverlayController} */
(this._overlayCtrl).removeEventListener(
'show',
/** @type {EventListener} */ (this.__onOverlayCtrlShow),
);
@ -290,9 +301,11 @@ export const OverlayMixinImplementation = superclass =>
__syncToOverlayController() {
if (this.opened) {
/** @type {OverlayController} */ (this._overlayCtrl).show();
/** @type {OverlayController} */
(this._overlayCtrl).show();
} else {
/** @type {OverlayController} */ (this._overlayCtrl).hide();
/** @type {OverlayController} */
(this._overlayCtrl).hide();
}
}
};

View file

@ -26,11 +26,9 @@ loadDefaultFeedbackMessages();
```js preview-story
export const main = () => html`
<lion-select-rich name="favoriteColor" label="Favorite color">
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-select-rich>
`;
```
@ -78,20 +76,18 @@ The main feature of this rich select that makes it rich, is that your options ca
```js preview-story
export const optionsWithHTML = () => html`
<lion-select-rich label="Favorite color" name="color">
<lion-options slot="input" class="demo-listbox">
<lion-option .modelValue=${{ value: 'red', checked: false }}>
<p style="color: red;">I am red</p>
<p>and multi Line</p>
</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>
<p style="color: hotpink;">I am hotpink</p>
<p>and multi Line</p>
</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>
<p style="color: teal;">I am teal</p>
<p>and multi Line</p>
</lion-option>
</lion-options>
<lion-option .modelValue=${{ value: 'red', checked: false }}>
<p style="color: red;">I am red</p>
<p>and multi Line</p>
</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>
<p style="color: hotpink;">I am hotpink</p>
<p>and multi Line</p>
</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>
<p style="color: teal;">I am teal</p>
<p>and multi Line</p>
</lion-option>
</lion-select-rich>
`;
```
@ -108,23 +104,21 @@ export const manyOptionsWithScrolling = () => html`
}
</style>
<lion-select-rich id="scrollSelectRich" label="Favorite color" name="color">
<lion-options slot="input" class="demo-listbox">
<lion-option .modelValue=${{ value: 'red', checked: false }}>
<p style="color: red;">I am red</p>
</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>
<p style="color: hotpink;">I am hotpink</p>
</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>
<p style="color: teal;">I am teal</p>
</lion-option>
<lion-option .modelValue=${{ value: 'green', checked: false }}>
<p style="color: green;">I am green</p>
</lion-option>
<lion-option .modelValue=${{ value: 'blue', checked: false }}>
<p style="color: blue;">I am blue</p>
</lion-option>
</lion-options>
<lion-option .modelValue=${{ value: 'red', checked: false }}>
<p style="color: red;">I am red</p>
</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>
<p style="color: hotpink;">I am hotpink</p>
</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>
<p style="color: teal;">I am teal</p>
</lion-option>
<lion-option .modelValue=${{ value: 'green', checked: false }}>
<p style="color: green;">I am green</p>
</lion-option>
<lion-option .modelValue=${{ value: 'blue', checked: false }}>
<p style="color: blue;">I am blue</p>
</lion-option>
</lion-select-rich>
`;
```
@ -139,11 +133,9 @@ The readonly attribute is delegated to the invoker for disabling opening the ove
```js preview-story
export const readOnlyPrefilled = () => html`
<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: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-options>
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-select-rich>
`;
```
@ -157,11 +149,9 @@ If you disable the entire select, the disabled attribute is also delegated to th
```js preview-story
export const disabledSelect = () => html`
<lion-select-rich label="Disabled select" disabled name="color">
<lion-options slot="input">
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-options>
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-select-rich>
`;
```
@ -169,13 +159,11 @@ export const disabledSelect = () => html`
```js preview-story
export const disabledOption = () => html`
<lion-select-rich label="Disabled options" name="color">
<lion-options slot="input">
<lion-option .choiceValue=${'red'} disabled>Red</lion-option>
<lion-option .choiceValue=${'blue'}>Blue</lion-option>
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
<lion-option .choiceValue=${'green'}>Green</lion-option>
<lion-option .choiceValue=${'teal'} disabled>Teal</lion-option>
</lion-options>
<lion-option .choiceValue=${'red'} disabled>Red</lion-option>
<lion-option .choiceValue=${'blue'}>Blue</lion-option>
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
<lion-option .choiceValue=${'green'}>Green</lion-option>
<lion-option .choiceValue=${'teal'} disabled>Teal</lion-option>
</lion-select-rich>
`;
```
@ -194,12 +182,10 @@ export const validation = () => {
label="Favorite color"
.validators="${[new Required()]}"
>
<lion-options slot="input" class="demo-listbox">
<lion-option .choiceValue=${null}>select a color</lion-option>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
<lion-option .choiceValue=${null}>select a color</lion-option>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} disabled>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-select-rich>
`;
};
@ -217,22 +203,20 @@ export const renderOptions = () => {
{ type: 'mastercard', label: 'Master Card', amount: 12000, active: true },
{ type: 'visacard', label: 'Visa Card', amount: 0, active: false },
];
function showOutput() {
function showOutput(ev) {
document.getElementById('demoRenderOutput').innerHTML = JSON.stringify(
this.checkedValue,
ev.target.modelValue,
null,
2,
);
}
return html`
<lion-select-rich label="Credit Card" name="color" @select-model-value-changed=${showOutput}>
<lion-options slot="input">
${objs.map(
obj => html`
<lion-option .modelValue=${{ value: obj, checked: false }}>${obj.label}</lion-option>
`,
)}
</lion-options>
<lion-select-rich label="Credit Card" name="color" @model-value-changed=${showOutput}>
${objs.map(
obj => html`
<lion-option .modelValue=${{ value: obj, checked: false }}>${obj.label}</lion-option>
`,
)}
</lion-select-rich>
<p>Full value:</p>
<pre id="demoRenderOutput"></pre>
@ -250,18 +234,14 @@ This changes the keyboard interaction.
```js preview-story
export const interactionMode = () => html`
<lion-select-rich label="Mac mode" name="color" interaction-mode="mac">
<lion-options slot="input">
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-options>
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-select-rich>
<lion-select-rich label="Windows/Linux mode" name="color" interaction-mode="windows/linux">
<lion-options slot="input">
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-options>
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>Hotpink</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>Teal</lion-option>
</lion-select-rich>
`;
```
@ -302,11 +282,9 @@ export const checkedIndexAndValue = () => html`
Console log checked index and value
</button>
<lion-select-rich id="checkedRichSelect" name="favoriteColor" label="Favorite color">
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-select-rich>
`;
```
@ -331,11 +309,9 @@ Both methods work with the `Required` validator.
```js preview-story
export const noDefaultSelection = () => html`
<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=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-select-rich>
`;
```
@ -350,9 +326,7 @@ If there is a single option rendered, then `singleOption` property is set to `tr
```js preview-story
export const singleOption = () => html`
<lion-select-rich label="Single Option" name="color">
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
</lion-options>
<lion-option .choiceValue=${'red'}>Red</lion-option>
</lion-select-rich>
`;
```
@ -416,7 +390,7 @@ You can use this `selectedElement` to then render the content to your own invoke
```html
<lion-select-rich>
<my-invoker-button slot="invoker"></my-invoker-button>
<lion-options slot="input"> ... </lion-options>
...
</lion-select-rich>
```

View file

@ -8,7 +8,6 @@ import { css, html } from '@lion/core';
/**
* LionSelectInvoker: invoker button consuming a selected element
*/
// @ts-expect-error static get sryles return type
export class LionSelectInvoker extends LionButton {
static get styles() {
return [

View file

@ -1,5 +1,5 @@
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 '@lion/core/src/differentKeyEventNamesShimIE.js';
import { LionSelectInvoker } from './LionSelectInvoker.js';
@ -13,7 +13,7 @@ import { LionSelectInvoker } from './LionSelectInvoker.js';
*/
function detectInteractionMode() {
if (navigator.appVersion.indexOf('Mac') !== -1) {
if (browserDetection.isMac) {
return 'mac';
}
return 'windows/linux';
@ -22,7 +22,6 @@ function detectInteractionMode() {
/**
* LionSelectRich: wraps the <lion-listbox> element
*/
// @ts-expect-error base constructors same return type
export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(LionListbox))) {
static get scopedElements() {
return {
@ -64,7 +63,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
}
get _scrollTargetNode() {
// @ts-expect-error _scrollTargetNode not on type
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.__invokerOnClick = this.__invokerOnClick.bind(this);
this.__overlayBeforeShow = this.__overlayBeforeShow.bind(this);
this.__focusInvokerOnLabelClick = this.__focusInvokerOnLabelClick.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.__setupInvokerNode();
this.__toggleInvokerDisabled();
if (this._labelNode) {
this._labelNode.addEventListener('click', this.__focusInvokerOnLabelClick);
}
this.addEventListener('keyup', this.__onKeyUp);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._labelNode) {
this._labelNode.removeEventListener('click', this.__focusInvokerOnLabelClick);
}
this.__teardownInvokerNode();
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
* when prefilling or programmatically setting it.
@ -159,16 +173,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
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)
*/
@ -178,14 +182,8 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
}
_onFormElementsChanged() {
if (this.formElements.length === 1 && this.singleOption === false) {
this.singleOption = true;
this._invokerNode.singleOption = true;
}
if (this.formElements.length !== 1 && this.singleOption === true) {
this.singleOption = false;
this._invokerNode.singleOption = false;
}
this.singleOption = this.formElements.length === 1;
this._invokerNode.singleOption = this.singleOption;
}
/**
@ -243,6 +241,7 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
<slot name="invoker"></slot>
<div id="overlay-content-node-wrapper">
<slot name="input"></slot>
<slot id="options-outlet"></slot>
</div>
</div>
`;
@ -350,7 +349,10 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
}
__focusInvokerOnLabelClick() {
/**
* @configure FormControlMixin
*/
_onLabelClick() {
this._invokerNode.focus();
}
@ -441,8 +443,8 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this.opened = false;
}
_setupListboxNodeInteractions() {
super._setupListboxNodeInteractions();
_setupListboxNode() {
super._setupListboxNode();
this._listboxNode.addEventListener('click', this._listboxOnClick);
}

View file

@ -1,5 +1,6 @@
import { Required } from '@lion/form-core';
import { expect, html, triggerBlurFor, triggerFocusFor, fixture } from '@open-wc/testing';
import { browserDetection } from '@lion/core';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import '@lion/listbox/lion-option.js';
@ -7,66 +8,56 @@ import '@lion/listbox/lion-options.js';
import '../lion-select-rich.js';
describe('lion-select-rich interactions', () => {
describe('Keyboard navigation', () => {
it('navigates to first and last option with [Home] and [End] keys', async () => {
describe('Interaction mode', () => {
it('autodetects interactionMode if not defined', async () => {
const originalIsMac = browserDetection.isMac;
browserDetection.isMac = true;
const el = await fixture(html`
<lion-select-rich opened interaction-mode="windows/linux">
<lion-options slot="input" name="foo">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30} checked>Item 3</lion-option>
<lion-option .choiceValue=${40}>Item 4</lion-option>
</lion-options>
</lion-select-rich>
<lion-select-rich><lion-option .choiceValue=${10}>Item 1</lion-option></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' }));
expect(el.modelValue).to.equal(10);
browserDetection.isMac = false;
const el3 = await fixture(html`
<lion-select-rich><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich>
`);
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;
});
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
expect(el.modelValue).to.equal(40);
it('derives selectionFollowsFocus and navigateWithinInvoker from interactionMode', async () => {
const el = await fixture(html`
<lion-select-rich interaction-mode="windows/linux"
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
>
`);
expect(el.selectionFollowsFocus).to.be.true;
expect(el.navigateWithinInvoker).to.be.true;
const el2 = await fixture(html`
<lion-select-rich interaction-mode="mac"
><lion-option .choiceValue=${10}>Item 1</lion-option></lion-select-rich
>
`);
expect(el2.selectionFollowsFocus).to.be.false;
expect(el2.navigateWithinInvoker).to.be.false;
});
});
describe('Keyboard navigation Windows', () => {
it('navigates through list with [ArrowDown] [ArrowUp] keys activates and checks the option', async () => {
function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) {
options.forEach((option, i) => {
if (i === selectedIndex) {
expect(option.checked).to.be.true;
} else {
expect(option.checked).to.be.false;
}
});
}
const el = await fixture(html`
<lion-select-rich opened interaction-mode="windows/linux">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
<lion-option .choiceValue=${30}>Item 3</lion-option>
</lion-options>
</lion-select-rich>
`);
const options = Array.from(el.querySelectorAll('lion-option'));
expect(el.activeIndex).to.equal(0);
expect(el.checkedIndex).to.equal(0);
expectOnlyGivenOneOptionToBeChecked(options, 0);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(el.activeIndex).to.equal(1);
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 () => {
function expectOnlyGivenOneOptionToBeChecked(options, selectedIndex) {
options.forEach((option, i) => {
@ -103,7 +94,7 @@ describe('lion-select-rich interactions', () => {
});
describe('Disabled', () => {
it('cannot be focused if disabled', async () => {
it('invoker cannot be focused if disabled', async () => {
const el = await fixture(html`
<lion-select-rich disabled>
<lion-options slot="input"></lion-options>

View file

@ -18,23 +18,39 @@ import '../lion-select-rich.js';
describe('lion-select-rich', () => {
it('clicking the label should focus the invoker', async () => {
const el = await fixture(html`
<lion-select-rich label="foo">
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich label="foo"> </lion-select-rich> `);
expect(document.activeElement === document.body).to.be.true;
el._labelNode.click();
expect(document.activeElement === el._invokerNode).to.be.true;
});
it('checks the first enabled option', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-option .choiceValue=${'Red'}></lion-option>
<lion-option .choiceValue=${'Hotpink'}></lion-option>
<lion-option .choiceValue=${'Blue'}></lion-option>
</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-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
expect(el._invokerNode).to.exist;
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 () => {
const el = await fixture(html`
<lion-select-rich name="foo">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
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 () => {
const el = await fixture(html`
<lion-select-rich name="foo">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-select-rich>
`);
const options = el.querySelectorAll('lion-option');
@ -73,34 +85,35 @@ describe('lion-select-rich', () => {
it('delegates readonly to the invoker', async () => {
const el = await fixture(html`
<lion-select-rich readonly>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
expect(el.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', () => {
it('should be closed by default', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
expect(el.opened).to.be.false;
});
it('shows/hides the listbox via opened attribute', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
el.opened = true;
await el.updateComplete;
expect(el._overlayCtrl.isShown).to.be.true;
@ -111,11 +124,7 @@ describe('lion-select-rich', () => {
});
it('syncs opened state with overlay shown', async () => {
const el = await fixture(html`
<lion-select-rich .opened=${true}>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich .opened=${true}></lion-select-rich> `);
const outerEl = await fixture('<button>somewhere</button>');
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 () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich></lion-select-rich> `);
await el._overlayCtrl.show();
await el.updateComplete;
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 () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-select-rich>
`);
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 () => {
const elReadOnly = await fixture(html`
<lion-select-rich readonly>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-select-rich>
`);
const elDisabled = await fixture(html`
<lion-select-rich disabled>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20} checked>Item 2</lion-option>
</lion-select-rich>
`);
const elSingleoption = await fixture(html`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-select-rich>
`);
@ -203,11 +200,9 @@ describe('lion-select-rich', () => {
it('sets inheritsReferenceWidth to min by default', async () => {
const el = await fixture(html`
<lion-select-rich name="favoriteColor" label="Favorite color">
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</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 () => {
const el = await fixture(html`
<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=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'}>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-select-rich>
`);
@ -252,10 +245,8 @@ describe('lion-select-rich', () => {
it('should have singleOption only if there is exactly one option', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
expect(el.singleOption).to.be.false;
@ -281,9 +272,7 @@ describe('lion-select-rich', () => {
describe('interaction-mode', () => {
it('allows to specify an interaction-mode which determines other behaviors', async () => {
const el = await fixture(html`
<lion-select-rich interaction-mode="mac">
<lion-options slot="input"></lion-options>
</lion-select-rich>
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
`);
expect(el.interactionMode).to.equal('mac');
});
@ -291,43 +280,27 @@ describe('lion-select-rich', () => {
describe('Keyboard navigation', () => {
it('opens the listbox with [Enter] key via click handler', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
el._invokerNode.click();
await aTimeout();
expect(el.opened).to.be.true;
});
it('opens the listbox with [ ](Space) key via click handler', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
el._invokerNode.click();
await aTimeout();
expect(el.opened).to.be.true;
});
it('closes the listbox with [Escape] key once opened', async () => {
const el = await fixture(html`
<lion-select-rich opened>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich opened> </lion-select-rich> `);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(el.opened).to.be.false;
});
it('closes the listbox with [Tab] key once opened', async () => {
const el = await fixture(html`
<lion-select-rich opened>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich opened> </lion-select-rich> `);
// tab can only be caught via keydown
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
expect(el.opened).to.be.false;
@ -336,11 +309,7 @@ describe('lion-select-rich', () => {
describe('Mouse navigation', () => {
it('opens the listbox via click on invoker', async () => {
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
expect(el.opened).to.be.false;
el._invokerNode.click();
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 () => {
const el = await fixture(html`
<lion-select-rich opened>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
</lion-select-rich>
`);
expect(el.opened).to.be.true;
@ -363,11 +330,7 @@ describe('lion-select-rich', () => {
describe('Keyboard navigation Windows', () => {
it('closes the listbox with [Enter] key once opened', async () => {
const el = await fixture(html`
<lion-select-rich opened>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich opened> </lion-select-rich> `);
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
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 () => {
const el = await fixture(html`
<lion-select-rich opened interaction-mode="mac">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
@ -395,9 +356,7 @@ describe('lion-select-rich', () => {
it('opens the listbox with [ArrowUp] key', async () => {
const el = await fixture(html`
<lion-select-rich interaction-mode="mac">
<lion-options slot="input"></lion-options>
</lion-select-rich>
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
`);
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' }));
await el.updateComplete;
@ -406,9 +365,7 @@ describe('lion-select-rich', () => {
it('opens the listbox with [ArrowDown] key', async () => {
const el = await fixture(html`
<lion-select-rich interaction-mode="mac">
<lion-options slot="input"></lion-options>
</lion-select-rich>
<lion-select-rich interaction-mode="mac"> </lion-select-rich>
`);
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
await el.updateComplete;
@ -420,10 +377,8 @@ describe('lion-select-rich', () => {
it('has the right references to its inner elements', async () => {
const el = await fixture(html`
<lion-select-rich label="age">
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
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 () => {
// smoke test for overlay functionality
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`);
const el = await fixture(html` <lion-select-rich> </lion-select-rich> `);
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false');
el.opened = true;
await el.updateComplete;
@ -483,16 +434,13 @@ describe('lion-select-rich', () => {
render() {
return html`
<lion-select-rich label="Favorite color" name="color">
<lion-options slot="input">
${this.colorList.map(
colorObj => html`
<lion-option
.modelValue=${{ value: colorObj.value, checked: colorObj.checked }}
>${colorObj.label}</lion-option
>
`,
)}
</lion-options>
${this.colorList.map(
colorObj => html`
<lion-option .modelValue=${{ value: colorObj.value, checked: colorObj.checked }}
>${colorObj.label}</lion-option
>
`,
)}
</lion-select-rich>
`;
}
@ -545,13 +493,13 @@ describe('lion-select-rich', () => {
const el = await fixture(html`
<${mySelectTag} label="Favorite color" name="color">
<lion-options slot="input">
${Array(2).map(
(_, i) => html`
<lion-option .modelValue="${{ value: i, checked: false }}">value ${i}</lion-option>
`,
)}
</lion-options>
</${mySelectTag}>
`);
await el.updateComplete;
@ -584,11 +532,11 @@ describe('lion-select-rich', () => {
const el = await fixture(html`
<${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=${'hotpink'} disabled>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
</${selectTag}>
`);