From 01a798e59eb622da2f866036056a9c81c82fe313 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Wed, 30 Sep 2020 12:05:36 +0200 Subject: [PATCH] feat(combobox): new package combobox Co-authored-by: Joren Broekema --- .changeset/new-tools-march.md | 25 + .storybook/preview-head.html | 10 +- README.md | 2 + packages/combobox/CHANGELOG.md | 1 + packages/combobox/README.md | 204 +++ packages/combobox/docs/Subclassers.md | 23 + .../docs/lion-combobox-selection-display.js | 197 +++ .../combobox/docs/md-combobox/MdFieldMixin.js | 340 ++++ .../combobox/docs/md-combobox/md-combobox.js | 89 + .../combobox/docs/md-combobox/md-input.js | 5 + .../docs/md-combobox/style/load-roboto.js | 6 + .../docs/md-combobox/style/md-ripple.js | 81 + packages/combobox/index.js | 1 + packages/combobox/lion-combobox.js | 3 + packages/combobox/package.json | 48 + packages/combobox/src/LionCombobox.js | 653 ++++++++ .../test/lion-combobox-integrations.test.js | 4 + packages/combobox/test/lion-combobox.test.js | 1021 ++++++++++++ packages/core/src/browserDetection.js | 40 +- packages/core/src/closestPolyfill.js | 23 + ...tTargetShim.js => EventTargetShim.test.js} | 0 packages/form-core/src/FormControlMixin.js | 107 +- .../src/choice-group/ChoiceGroupMixin.js | 2 +- .../test-suites/FormatMixin.suite.js | 3 +- .../choice-group/ChoiceGroupMixin.suite.js | 393 +++++ .../form-core/test/FormControlMixin.test.js | 28 +- .../choice-group/ChoiceGroupMixin.test.js | 395 +---- .../types/FormControlMixinTypes.d.ts | 2 - .../docs/15-features-overview.md | 8 +- .../docs/60-dialog-integration.md | 4 +- packages/listbox/README.md | 27 +- packages/listbox/src/LionOption.js | 2 +- packages/listbox/src/LionOptions.js | 2 - packages/listbox/src/ListboxMixin.js | 687 ++++---- .../listbox/test-suites/ListboxMixin.suite.js | 1457 +++++++++-------- packages/listbox/types/ListboxMixinTypes.d.ts | 2 +- packages/overlays/src/OverlayController.js | 10 +- packages/overlays/src/OverlayMixin.js | 25 +- packages/select-rich/README.md | 160 +- packages/select-rich/src/LionSelectInvoker.js | 1 - packages/select-rich/src/LionSelectRich.js | 68 +- .../test/lion-select-rich-interaction.test.js | 99 +- .../select-rich/test/lion-select-rich.test.js | 228 +-- 43 files changed, 4724 insertions(+), 1762 deletions(-) create mode 100644 .changeset/new-tools-march.md create mode 100644 packages/combobox/CHANGELOG.md create mode 100644 packages/combobox/README.md create mode 100644 packages/combobox/docs/Subclassers.md create mode 100644 packages/combobox/docs/lion-combobox-selection-display.js create mode 100644 packages/combobox/docs/md-combobox/MdFieldMixin.js create mode 100644 packages/combobox/docs/md-combobox/md-combobox.js create mode 100644 packages/combobox/docs/md-combobox/md-input.js create mode 100644 packages/combobox/docs/md-combobox/style/load-roboto.js create mode 100644 packages/combobox/docs/md-combobox/style/md-ripple.js create mode 100644 packages/combobox/index.js create mode 100644 packages/combobox/lion-combobox.js create mode 100644 packages/combobox/package.json create mode 100644 packages/combobox/src/LionCombobox.js create mode 100644 packages/combobox/test/lion-combobox-integrations.test.js create mode 100644 packages/combobox/test/lion-combobox.test.js create mode 100644 packages/core/src/closestPolyfill.js rename packages/core/test/{EventTargetShim.js => EventTargetShim.test.js} (100%) create mode 100644 packages/form-core/test-suites/choice-group/ChoiceGroupMixin.suite.js diff --git a/.changeset/new-tools-march.md b/.changeset/new-tools-march.md new file mode 100644 index 000000000..372b8469e --- /dev/null +++ b/.changeset/new-tools-march.md @@ -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 diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index aac00cfed..dcd7618be 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -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; } diff --git a/README.md b/README.md index 664bf80c7..f89509d99 100644 --- a/README.md +++ b/README.md @@ -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] | diff --git a/packages/combobox/CHANGELOG.md b/packages/combobox/CHANGELOG.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/packages/combobox/CHANGELOG.md @@ -0,0 +1 @@ + diff --git a/packages/combobox/README.md b/packages/combobox/README.md new file mode 100644 index 000000000..5a9ea2cdf --- /dev/null +++ b/packages/combobox/README.md @@ -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` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +## 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` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +When `autocomplete="list"` is configured, it will filter listbox suggestions based on textbox value. + +```js preview-story +export const autocompleteList = () => html` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +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` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +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` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +## 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` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +```js preview-story +export const matchModeAll = () => html` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +## Changing defaults + +By default `selection-follows-focus` will be true (aligned with the +wai-aria examples and the natve ``). +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` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +By default `rotate-keyboard-navigation` will be true (aligned with the +wai-aria examples and the natve ``). +It is possible to disable this behavior, see example below + +```js preview-story +export const noRotateKeyboardNavigation = () => html` + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +## 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` + + + ${listboxData.map( + (entry, i) => + html` ${entry} `, + )} + +`; +``` + +## Invoker button + +```js preview-story +export const invokerButton = () => html` + + + ${listboxData.map(entry => html` ${entry} `)} + +`; +``` + +## 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. diff --git a/packages/combobox/docs/Subclassers.md b/packages/combobox/docs/Subclassers.md new file mode 100644 index 000000000..aee741e59 --- /dev/null +++ b/packages/combobox/docs/Subclassers.md @@ -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` + + Apple + Artichoke + Asparagus + Banana + Beets + +`; +``` diff --git a/packages/combobox/docs/lion-combobox-selection-display.js b/packages/combobox/docs/lion-combobox-selection-display.js new file mode 100644 index 000000000..741938eb4 --- /dev/null +++ b/packages/combobox/docs/lion-combobox-selection-display.js @@ -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` + + ${option.value} + + `; + } + + _selectedElementsTemplate() { + if (!this.multipleChoice) { + return nothing; + } + return html` +
+ ${this.selectedElements.map((option, i) => { + const highlight = Boolean( + this.removeChipOnNextBackspace && i === this.selectedElements.length - 1, + ); + return this._selectedElementTemplate(option, highlight); + })} +
+ `; + } + + 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); diff --git a/packages/combobox/docs/md-combobox/MdFieldMixin.js b/packages/combobox/docs/md-combobox/MdFieldMixin.js new file mode 100644 index 000000000..7dd5586a5 --- /dev/null +++ b/packages/combobox/docs/md-combobox/MdFieldMixin.js @@ -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` +
+ ${this._labelTemplate()} + +
+ `; + } + + /** + * @enhance FormControlMixin + */ + _inputGroupTemplate() { + return html` +
+ ${this._inputGroupBeforeTemplate()} +
+ ${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()} + ${this._inputGroupSuffixTemplate()} +
+ +
+
+ ${this._inputGroupAfterTemplate()} +
+ `; + } + }, +); diff --git a/packages/combobox/docs/md-combobox/md-combobox.js b/packages/combobox/docs/md-combobox/md-combobox.js new file mode 100644 index 000000000..39469d6e4 --- /dev/null +++ b/packages/combobox/docs/md-combobox/md-combobox.js @@ -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'), + `$1`, + ); + this.style.display = ''; + } + + /** + * @override + */ + onFilterUnmatch() { + if (this.__originalInnerHTML) { + this.innerHTML = this.__originalInnerHTML; + } + this.style.display = 'none'; + } + + render() { + return html` + ${super.render()} + + `; + } +} +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); diff --git a/packages/combobox/docs/md-combobox/md-input.js b/packages/combobox/docs/md-combobox/md-input.js new file mode 100644 index 000000000..6ff5675b4 --- /dev/null +++ b/packages/combobox/docs/md-combobox/md-input.js @@ -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); diff --git a/packages/combobox/docs/md-combobox/style/load-roboto.js b/packages/combobox/docs/md-combobox/style/load-roboto.js new file mode 100644 index 000000000..182c1aaf4 --- /dev/null +++ b/packages/combobox/docs/md-combobox/style/load-roboto.js @@ -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); diff --git a/packages/combobox/docs/md-combobox/style/md-ripple.js b/packages/combobox/docs/md-combobox/style/md-ripple.js new file mode 100644 index 000000000..3b175f24f --- /dev/null +++ b/packages/combobox/docs/md-combobox/style/md-ripple.js @@ -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`
`; + } + + 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); diff --git a/packages/combobox/index.js b/packages/combobox/index.js new file mode 100644 index 000000000..d4ab8168e --- /dev/null +++ b/packages/combobox/index.js @@ -0,0 +1 @@ +export { LionCombobox } from './src/LionCombobox.js'; diff --git a/packages/combobox/lion-combobox.js b/packages/combobox/lion-combobox.js new file mode 100644 index 000000000..bb1ed0236 --- /dev/null +++ b/packages/combobox/lion-combobox.js @@ -0,0 +1,3 @@ +import { LionCombobox } from './src/LionCombobox.js'; + +customElements.define('lion-combobox', LionCombobox); diff --git a/packages/combobox/package.json b/packages/combobox/package.json new file mode 100644 index 000000000..aa2bdf3a4 --- /dev/null +++ b/packages/combobox/package.json @@ -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" + } +} diff --git a/packages/combobox/src/LionCombobox.js b/packages/combobox/src/LionCombobox.js new file mode 100644 index 000000000..e2c5f3bd5 --- /dev/null +++ b/packages/combobox/src/LionCombobox.js @@ -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` +
+ + +
+ `; + } + + // eslint-disable-next-line class-methods-use-this + _overlayListboxTemplate() { + return html` + + + + `; + } + + /** + * @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 () 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 rotate + */ + this.rotateKeyboardNavigation = true; + /** + * @configure ListboxMixin: the wai-aria pattern and 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'), `$1`); + // For Safari, we need to add a label to the element + option.innerHTML = `${newInnerHTML}`; + // 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; + } +} diff --git a/packages/combobox/test/lion-combobox-integrations.test.js b/packages/combobox/test/lion-combobox-integrations.test.js new file mode 100644 index 000000000..99decfa06 --- /dev/null +++ b/packages/combobox/test/lion-combobox-integrations.test.js @@ -0,0 +1,4 @@ +import { runListboxMixinSuite } from '@lion/listbox/test-suites/ListboxMixin.suite.js'; +import '../lion-combobox.js'; + +runListboxMixinSuite({ tagString: 'lion-combobox' }); diff --git a/packages/combobox/test/lion-combobox.test.js b/packages/combobox/test/lion-combobox.test.js new file mode 100644 index 000000000..53070edad --- /dev/null +++ b/packages/combobox/test/lion-combobox.test.js @@ -0,0 +1,1021 @@ +import '@lion/listbox/lion-option.js'; +import { expect, fixture, html, defineCE, unsafeStatic } from '@open-wc/testing'; +import sinon from 'sinon'; +import '../lion-combobox.js'; +import { LionOptions } from '@lion/listbox/src/LionOptions.js'; +import { browserDetection, LitElement } from '@lion/core'; + +/** + * @typedef {import('../src/LionCombobox.js').LionCombobox} LionCombobox + */ + +/** + * @param {LionCombobox} el + * @param {string} value + */ +function mimicUserTyping(el, value) { + el._inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); + // eslint-disable-next-line no-param-reassign + el._inputNode.value = value; + el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + el._overlayInvokerNode.dispatchEvent(new Event('keydown')); +} + +/** + * @param {LionCombobox} el + */ +function getFilteredOptionValues(el) { + const options = el.formElements; + /** + * @param {{ style: { display: string; }; }} option + */ + const filtered = options.filter(option => option.getAttribute('aria-hidden') !== 'true'); + /** + * @param {{ value: any; }} option + */ + return filtered.map(option => option.value); +} + +/** + * @param {{ autocomplete?:'none'|'list'|'both', matchMode?:'begin'|'all' }} [config] + */ +async function fruitFixture({ autocomplete, matchMode } = {}) { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + if (autocomplete) { + el.autocomplete = autocomplete; + } + if (matchMode) { + el.matchMode = matchMode; + } + await el.updateComplete; + return [el, el.formElements]; +} + +describe('lion-combobox', () => { + describe('Structure', () => { + it('has a listbox node', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + Item 2 + + `)); + expect(el._listboxNode).to.exist; + expect(el._listboxNode).to.be.instanceOf(LionOptions); + expect(el.querySelector('[role=listbox]')).to.equal(el._listboxNode); + }); + + it('has a textbox element', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + Item 2 + + `)); + expect(el._comboboxNode).to.exist; + expect(el.querySelector('[role=combobox]')).to.equal(el._comboboxNode); + }); + }); + + describe('Values', () => { + it('syncs modelValue with textbox', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + Item 2 + + `)); + expect(el._inputNode.value).to.equal('10'); + + el.modelValue = '20'; + await el.updateComplete; + expect(el._inputNode.value).to.equal('20'); + }); + }); + + describe('Listbox visibility', () => { + it('does not show listbox on focusin', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + expect(el.opened).to.equal(false); + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.opened).to.equal(false); + }); + + it('shows listbox again after select and char keydown', async () => { + /** + * Scenario: + * [1] user focuses textbox: listbox hidden + * [2] user types char: listbox shows + * [3] user selects "Artichoke": listbox closes, textbox gets value "Artichoke" and textbox + * still has focus + * [4] user changes textbox value to "Artichoke": the listbox should show again + */ + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + const options = el.formElements; + expect(el.opened).to.equal(false); + + // step [1] + el._inputNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.opened).to.equal(false); + + // step [2] + mimicUserTyping(el, 'c'); + await el.updateComplete; + expect(el.opened).to.equal(true); + + // step [3] + options[0].click(); + await el.updateComplete; + expect(el.opened).to.equal(false); + expect(document.activeElement).to.equal(el._inputNode); + + // step [4] + el._inputNode.value = ''; + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })); + await el.updateComplete; + expect(el.opened).to.equal(true); + }); + + it('hides (and clears) listbox on [Escape]', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + // open + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + + mimicUserTyping(el, 'art'); + await el.updateComplete; + expect(el.opened).to.equal(true); + expect(el._inputNode.value).to.equal('Artichoke'); + + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(el.opened).to.equal(false); + expect(el._inputNode.value).to.equal(''); + }); + + it('hides listbox on [Tab]', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + // open + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + + mimicUserTyping(el, 'art'); + await el.updateComplete; + expect(el.opened).to.equal(true); + expect(el._inputNode.value).to.equal('Artichoke'); + + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + expect(el.opened).to.equal(false); + expect(el._inputNode.value).to.equal('Artichoke'); + }); + + it('clears checkedIndex on empty text', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + // open + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + + mimicUserTyping(el, 'art'); + await el.updateComplete; + expect(el.opened).to.equal(true); + expect(el._inputNode.value).to.equal('Artichoke'); + expect(el.checkedIndex).to.equal(0); + + el._inputNode.value = ''; + mimicUserTyping(el, ''); + el.opened = false; + await el.updateComplete; + expect(el.checkedIndex).to.equal(-1); + }); + + describe('Accessibility', () => { + it('sets "aria-posinset" and "aria-setsize" on visible entries', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + const options = el.formElements; + expect(el.opened).to.equal(false); + + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + + mimicUserTyping(el, 'art'); + await el.updateComplete; + expect(el.opened).to.equal(true); + + const visibleOptions = options.filter(o => o.style.display !== 'none'); + visibleOptions.forEach((o, i) => { + expect(o.getAttribute('aria-posinset')).to.equal(`${i + 1}`); + expect(o.getAttribute('aria-setsize')).to.equal(`${visibleOptions.length}`); + }); + /** + * @param {{ style: { display: string; }; }} o + */ + const hiddenOptions = options.filter(o => o.style.display === 'none'); + /** + * @param {{ hasAttribute: (arg0: string) => any; }} o + */ + hiddenOptions.forEach(o => { + expect(o.hasAttribute('aria-posinset')).to.equal(false); + expect(o.hasAttribute('aria-setsize')).to.equal(false); + }); + }); + + /** + * Note that we use aria-hidden instead of 'display:none' to allow for animations + * (like fade in/out) + */ + it('sets aria-hidden="true" on hidden entries', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + const options = el.formElements; + expect(el.opened).to.equal(false); + + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + + mimicUserTyping(el, 'art'); + expect(el.opened).to.equal(true); + await el.updateComplete; + + const visibleOptions = options.filter(o => o.style.display !== 'none'); + visibleOptions.forEach(o => { + expect(o.hasAttribute('aria-hidden')).to.be.false; + }); + const hiddenOptions = options.filter(o => o.style.display === 'none'); + hiddenOptions.forEach(o => { + expect(o.getAttribute('aria-hidden')).to.equal('true'); + }); + }); + }); + }); + + // Notice that the LionComboboxInvoker always needs to be used in conjunction with the + // LionCombobox, and therefore will be tested integrated, + describe('Invoker component integration', () => { + describe('Accessibility', () => { + it('sets role="combobox" on textbox wrapper/listbox sibling', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + Item 2 + + `)); + expect(el._comboboxNode.getAttribute('role')).to.equal('combobox'); + }); + + it('makes sure listbox node is not focusable', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + Item 2 + + `)); + expect(el._listboxNode.hasAttribute('tabindex')).to.be.false; + }); + }); + }); + + describe('Selection display', () => { + class MySelectionDisplay extends LitElement { + onComboboxElementUpdated(changedProperties) { + if (changedProperties.has('modelValue') && this.comboboxElement.multipleChoice) { + // do smth.. + } + } + } + const selectionDisplayTag = unsafeStatic(defineCE(MySelectionDisplay)); + + it('stores internal reference _selectionDisplayNode in LionCombobox', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + <${selectionDisplayTag} slot="selection-display"> + Item 1 + + `)); + expect(el._selectionDisplayNode).to.equal(el.querySelector('[slot=selection-display]')); + }); + + it('sets a reference to combobox element in _selectionDisplayNode', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + <${selectionDisplayTag} slot="selection-display"> + Item 1 + + `)); + expect(el._selectionDisplayNode.comboboxElement).to.equal(el); + }); + + it('calls "onComboboxElementUpdated(changedProperties)" on "updated" in _selectionDisplayNode', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + <${selectionDisplayTag} slot="selection-display"> + Item 1 + + `)); + const spy = sinon.spy(el._selectionDisplayNode, 'onComboboxElementUpdated'); + el.requestUpdate('modelValue'); + await el.updateComplete; + expect(spy).to.have.been.calledOnce; + }); + + // TODO: put those in distinct file if ./docs/lion-combobox-selection-display.js is accessible + // and exposable + describe.skip('Selected chips display', () => { + // it('displays chips next to textbox, ordered based on user selection', async () => { + // const el = /** @type {LionCombobox} */ (await fixture(html` + // + // Artichoke + // Chard + // Chicory + // Victoria Plum + // + // `)); + // const options = el.formElements; + // options[2].checked = true; // Chicory + // options[0].checked = true; // Artichoke + // options[1].checked = true; // Chard + // const chips = Array.from(el._comboboxNode.querySelectorAll('.selection-chip')); + // expect(chips.map(elm => elm.textContent)).to.eql(['Chicory', 'Artichoke', 'Chard']); + // expect(el._comboboxNode.selectedElements).to.eql([options[2], options[0], options[1]]); + // }); + // it('stages deletable chips on [Backspace]', async () => { + // const el = /** @type {LionCombobox} */ (await fixture(html` + // + // Artichoke + // Chard + // Chicory + // + // `)); + // const options = el.formElements; + // options[0].checked = true; // Artichoke + // options[1].checked = true; // Chard + // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.false; + // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); + // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.true; + // el._inputNode.blur(); + // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.false; + // }); + // it('deletes staged chip on [Backspace]', async () => { + // const el = /** @type {LionCombobox} */ (await fixture(html` + // + // Artichoke + // Chard + // Chicory + // + // `)); + // const options = el.formElements; + // options[0].checked = true; // Artichoke + // options[1].checked = true; // Chard + // expect(el._comboboxNode.selectedElements).to.eql([options[0], options[1]]); + // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.false; + // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); + // expect(el._comboboxNode.selectedElements).to.eql([options[0], options[1]]); + // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.true; + // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); + // expect(el._comboboxNode.selectedElements).to.eql([options[0]]); + // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.true; + // el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); + // expect(el._comboboxNode.selectedElements).to.eql([]); + // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.true; + // el._inputNode.blur(); + // expect(el._comboboxNode.removeChipOnNextBackspace).to.be.false; + // }); + }); + }); + + describe('Autocompletion', () => { + it('has autocomplete "both" by default', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + + `)); + expect(el.autocomplete).to.equal('both'); + }); + + it('filters options when autocomplete is "both"', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(getFilteredOptionValues(el)).to.eql(['Artichoke', 'Chard', 'Chicory']); + }); + + it('completes textbox when autocomplete is "both"', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(el._inputNode.value).to.equal('Chard'); + expect(el._inputNode.selectionStart).to.equal(2); + expect(el._inputNode.selectionEnd).to.equal(el._inputNode.value.length); + + // We don't autocomplete when characters are removed + mimicUserTyping(el, 'c'); // The user pressed backspace (number of chars decreased) + expect(el._inputNode.value).to.equal('c'); + expect(el._inputNode.selectionStart).to.equal(el._inputNode.value.length); + }); + + it('filters options when autocomplete is "list"', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(getFilteredOptionValues(el)).to.eql(['Artichoke', 'Chard', 'Chicory']); + expect(el._inputNode.value).to.equal('ch'); + }); + + it('does not filter options when autocomplete is "none"', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(getFilteredOptionValues(el)).to.eql([ + 'Artichoke', + 'Chard', + 'Chicory', + 'Victoria Plum', + ]); + }); + + it('does not filter options when autocomplete is "inline"', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(getFilteredOptionValues(el)).to.eql([ + 'Artichoke', + 'Chard', + 'Chicory', + 'Victoria Plum', + ]); + }); + + it('resets "checkedIndex" when going from matched to unmatched textbox value', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(el.checkedIndex).to.equal(1); + + mimicUserTyping(el, 'cho'); + await el.updateComplete; + expect(el.checkedIndex).to.equal(-1); + + // Works for autocomplete 'both' as well. + const el2 = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + mimicUserTyping(el2, 'ch'); + await el2.updateComplete; + expect(el2.checkedIndex).to.equal(1); + + // Also works when 'diminishing amount of chars' + mimicUserTyping(el2, 'x'); + await el2.updateComplete; + expect(el2.checkedIndex).to.equal(-1); + }); + + it('completes chars inside textbox', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(el._inputNode.value).to.equal('Chard'); + expect(el._inputNode.selectionStart).to.equal('ch'.length); + expect(el._inputNode.selectionEnd).to.equal('Chard'.length); + + mimicUserTyping(el, 'chic'); + await el.updateComplete; + expect(el._inputNode.value).to.equal('Chicory'); + expect(el._inputNode.selectionStart).to.equal('chic'.length); + expect(el._inputNode.selectionEnd).to.equal('Chicory'.length); + + // Diminishing chars, but autocompleting + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(el._inputNode.value).to.equal('ch'); + expect(el._inputNode.selectionStart).to.equal(2); + expect(el._inputNode.selectionEnd).to.equal(2); + }); + + it('does autocompletion when adding chars', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(el.activeIndex).to.equal(1); + expect(el.checkedIndex).to.equal(1); + + mimicUserTyping(el, 'chic'); + await el.updateComplete; + expect(el.activeIndex).to.equal(2); + expect(el.checkedIndex).to.equal(2); + + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(el.activeIndex).to.equal(2); + expect(el.checkedIndex).to.equal(-1); + }); + + it('does autocompletion when changing the word', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + mimicUserTyping(el, 'ch'); + await el.updateComplete; + expect(el.activeIndex).to.equal(1); + expect(el.checkedIndex).to.equal(1); + + mimicUserTyping(el, 'chic'); + await el.updateComplete; + expect(el.activeIndex).to.equal(2); + expect(el.checkedIndex).to.equal(2); + + // Diminishing chars, but autocompleting + mimicUserTyping(el, 'a'); + await el.updateComplete; + expect(el.activeIndex).to.equal(0); + expect(el.checkedIndex).to.equal(0); + }); + + it('highlights matching options', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + const options = el.formElements; + + mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); + + await el.updateComplete; + expect(options[0]).lightDom.to.equal(`Artichoke`); + expect(options[1]).lightDom.to.equal(`Chard`); + expect(options[2]).lightDom.to.equal(`Chicory`); + expect(options[3]).lightDom.to.equal(`Victoria Plum`); + + mimicUserTyping(/** @type {LionCombobox} */ (el), 'D'); + + await el.updateComplete; + expect(options[0]).lightDom.to.equal(`Artichoke`); + expect(options[1]).lightDom.to.equal(`Chard`); + expect(options[2]).lightDom.to.equal(`Chicory`); + expect(options[3]).lightDom.to.equal(`Victoria Plum`); + }); + + describe('Active index behavior', () => { + it('sets the active index to the closest match on open by default', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); + await el.updateComplete; + expect(el.activeIndex).to.equal(1); + }); + + it('changes whether active index is set to the closest match automatically depending on autocomplete', async () => { + /** + * Automatic selection (setting activeIndex to closest matching option) in lion is set for inline & both autocomplete, + * because it is unavoidable there + * For list & none autocomplete, it is turned off and manual selection is required. + * TODO: Make this configurable for list & none autocomplete? + */ + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html + // Example 1. List Autocomplete with Manual Selection: + // does not set active at all until user selects + el.autocomplete = 'none'; + mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); + await el.updateComplete; + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(el.activeIndex).to.equal(-1); + expect(el.opened).to.be.true; + + // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html + // Example 2. List Autocomplete with Automatic Selection: + // does not set active at all until user selects + el.autocomplete = 'list'; + mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); + await el.updateComplete; + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(el.activeIndex).to.equal(-1); + expect(el.opened).to.be.true; + + // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html + // Example 3. List with Inline Autocomplete (mostly, but with aria-autocomplete="inline") + el.autocomplete = 'inline'; + mimicUserTyping(/** @type {LionCombobox} */ (el), ''); + await el.updateComplete; + mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); + await el.updateComplete; + await el.updateComplete; + + // TODO: enable this, so it does not open listbox and is different from [autocomplete=both]? + // expect(el.opened).to.be.false; + expect(el.activeIndex).to.equal(1); + + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await el.updateComplete; + + expect(el.activeIndex).to.equal(-1); + expect(el.opened).to.be.false; + + // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html + // Example 3. List with Inline Autocomplete + el.autocomplete = 'both'; + mimicUserTyping(/** @type {LionCombobox} */ (el), ''); + await el.updateComplete; + mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); + await el.updateComplete; + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(el.activeIndex).to.equal(1); + expect(el.opened).to.be.false; + }); + + it('sets the active index to the closest match on autocomplete', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); + await el.updateComplete; + expect(el.activeIndex).to.equal(1); + + mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); + await el.updateComplete; + mimicUserTyping(/** @type {LionCombobox} */ (el), 'chi'); + // Chard no longer matches, so should switch active to Chicory + await el.updateComplete; + + expect(el.activeIndex).to.equal(2); + + // select artichoke + mimicUserTyping(/** @type {LionCombobox} */ (el), 'artichoke'); + await el.updateComplete; + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + mimicUserTyping(/** @type {LionCombobox} */ (el), ''); + await el.updateComplete; + // change selection, active index should update to closest match + mimicUserTyping(/** @type {LionCombobox} */ (el), 'vic'); + await el.updateComplete; + expect(el.activeIndex).to.equal(3); + }); + + it('supports clearing by [Escape] key and resets active state on all options', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + // Select something + mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); + await el.updateComplete; + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(el.activeIndex).to.equal(1); + + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + await el.updateComplete; + expect(el._inputNode.textContent).to.equal(''); + el.formElements.forEach(option => expect(option.active).to.be.false); + + // change selection, active index should update to closest match + mimicUserTyping(/** @type {LionCombobox} */ (el), 'vic'); + await el.updateComplete; + expect(el.activeIndex).to.equal(3); + }); + }); + + describe('Accessibility', () => { + it('synchronizes autocomplete option to textbox', async () => { + let el; + + [el] = await fruitFixture({ autocomplete: 'both' }); + expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('both'); + + [el] = await fruitFixture({ autocomplete: 'list' }); + expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('list'); + + [el] = await fruitFixture({ autocomplete: 'none' }); + expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('none'); + }); + + it('updates aria-activedescendant on textbox node', async () => { + let el = await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `); + + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal(null); + expect(el.formElements[1].active).to.equal(false); + + mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); + await el.updateComplete; + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal(null); + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( + 'artichoke-option', + ); + expect(el.formElements[1].active).to.equal(false); + + el = await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `); + mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); + await el.updateComplete; + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( + el.formElements[1].id, + ); + expect(el.formElements[1].active).to.equal(true); + + el.autocomplete = 'list'; + mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); + await el.updateComplete; + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( + el.formElements[1].id, + ); + expect(el.formElements[1].active).to.equal(true); + }); + + it('adds aria-label to highlighted options', async () => { + const [el, options] = await fruitFixture({ autocomplete: 'both', matchMode: 'all' }); + mimicUserTyping(/** @type {LionCombobox} */ (el), 'choke'); + await el.updateComplete; + const labelledElement = options[0].querySelector('span[aria-label="Artichoke"]'); + expect(labelledElement).to.not.be.null; + expect(labelledElement.innerText).to.equal('Artichoke'); + }); + }); + }); + + describe('Accessibility', () => { + describe('Aria versions', () => { + it('[role=combobox] wraps input node in v1.1', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + + `)); + expect(el._comboboxNode.contains(el._inputNode)).to.be.true; + }); + + it('has one input node with [role=combobox] in v1.0', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + + `)); + expect(el._comboboxNode).to.equal(el._inputNode); + }); + + it('autodetects aria version and sets it to 1.1 on Chromium browsers', async () => { + const browserDetectionIsChromiumOriginal = browserDetection.isChromium; + + browserDetection.isChromium = true; + const el = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + + `)); + expect(el._ariaVersion).to.equal('1.1'); + + browserDetection.isChromium = false; + const el2 = /** @type {LionCombobox} */ (await fixture(html` + + Item 1 + + `)); + expect(el2._ariaVersion).to.equal('1.0'); + + // restore... + browserDetection.isChromium = browserDetectionIsChromiumOriginal; + }); + }); + }); + + describe('Multiple Choice', () => { + // TODO: possibly later share test with select-rich if it officially supports multipleChoice + it('does not close listbox on click/enter/space', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + // activate opened listbox + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + mimicUserTyping(el, 'ch'); + await el.updateComplete; + + expect(el.opened).to.equal(true); + const visibleOptions = el.formElements.filter(o => o.style.display !== 'none'); + visibleOptions[0].click(); + expect(el.opened).to.equal(true); + // visibleOptions[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + // expect(el.opened).to.equal(true); + // visibleOptions[2].dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + // expect(el.opened).to.equal(true); + }); + }); + + describe('Match Mode', () => { + it('has a default value of "all"', async () => { + const [el] = await fruitFixture(); + expect(el.matchMode).to.equal('all'); + }); + + it('will suggest partial matches (in the middle of the word) when set to "all"', async () => { + const [el] = await fruitFixture(); + mimicUserTyping(/** @type {LionCombobox} */ (el), 'c'); + await el.updateComplete; + expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql([ + 'Artichoke', + 'Chard', + 'Chicory', + 'Victoria Plum', + ]); + }); + + it('will only suggest beginning matches when set to "begin"', async () => { + const [el] = await fruitFixture({ matchMode: 'begin' }); + mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); + await el.updateComplete; + expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql([ + 'Chard', + 'Chicory', + ]); + }); + + describe('Subclassers', () => { + it('allows for custom matching functions', async () => { + const [el] = await fruitFixture(); + /** + * @param {{ value: any; }} option + * @param {any} curValue + */ + function onlyExactMatches(option, curValue) { + return option.value === curValue; + } + el.filterOptionCondition = onlyExactMatches; + mimicUserTyping(/** @type {LionCombobox} */ (el), 'Chicory'); + await el.updateComplete; + expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql(['Chicory']); + mimicUserTyping(/** @type {LionCombobox} */ (el), 'Chicor'); + await el.updateComplete; + expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql([]); + }); + }); + }); +}); diff --git a/packages/core/src/browserDetection.js b/packages/core/src/browserDetection.js index 1a2b89745..7e1d6e85c 100644 --- a/packages/core/src/browserDetection.js +++ b/packages/core/src/browserDetection.js @@ -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, }; diff --git a/packages/core/src/closestPolyfill.js b/packages/core/src/closestPolyfill.js new file mode 100644 index 000000000..0835a7133 --- /dev/null +++ b/packages/core/src/closestPolyfill.js @@ -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; + }; +} diff --git a/packages/core/test/EventTargetShim.js b/packages/core/test/EventTargetShim.test.js similarity index 100% rename from packages/core/test/EventTargetShim.js rename to packages/core/test/EventTargetShim.test.js diff --git a/packages/form-core/src/FormControlMixin.js b/packages/form-core/src/FormControlMixin.js index 0ef37f189..1b1965547 100644 --- a/packages/form-core/src/FormControlMixin.js +++ b/packages/form-core/src/FormControlMixin.js @@ -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: *
@@ -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); diff --git a/packages/form-core/src/choice-group/ChoiceGroupMixin.js b/packages/form-core/src/choice-group/ChoiceGroupMixin.js index 597aba16f..f26b1f424 100644 --- a/packages/form-core/src/choice-group/ChoiceGroupMixin.js +++ b/packages/form-core/src/choice-group/ChoiceGroupMixin.js @@ -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); } diff --git a/packages/form-core/test-suites/FormatMixin.suite.js b/packages/form-core/test-suites/FormatMixin.suite.js index 58ff36f95..fda8f011b 100644 --- a/packages/form-core/test-suites/FormatMixin.suite.js +++ b/packages/form-core/test-suites/FormatMixin.suite.js @@ -47,8 +47,7 @@ function mimicUserInput(formControl, newViewValue) { export function runFormatMixinSuite(customConfig) { const cfg = { tagString: null, - modelValueType: String, - suffix: '', + childTagString: null, ...customConfig, }; diff --git a/packages/form-core/test-suites/choice-group/ChoiceGroupMixin.suite.js b/packages/form-core/test-suites/choice-group/ChoiceGroupMixin.suite.js new file mode 100644 index 000000000..60464c0e8 --- /dev/null +++ b/packages/form-core/test-suites/choice-group/ChoiceGroupMixin.suite.js @@ -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} .choiceValue=${'female'} checked> + <${childTag} .choiceValue=${'other'}> + + `)); + 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} .choiceValue=${'female'} checked> + <${childTag} .choiceValue=${'other'}> + + `)); + 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} .choiceValue=${'female'} checked> + <${childTag} .choiceValue=${'other'}> + + `)); + const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html` + <${childTag} .modelValue=${'Lara'}> + `)); + + 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} .choiceValue=${'other'}> + + `)); + + 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'}> + `)); + 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} .choiceValue=${'other'}> + + `)); + + const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html` + <${childTag} name="foo" .choiceValue=${'male'}> + `)); + + 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} .choiceValue=${'female'}> + <${childTag} .choiceValue=${'other'}> + + `)); + + 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} .choiceValue=${'female'}> + <${childTag} .choiceValue=${'other'}> + + `)); + + 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} .choiceValue=${'female'}> + <${childTag} .choiceValue=${'other'}> + + `)); + + 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} .choiceValue=${date} checked> + + `)); + + 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} .choiceValue=${''}> + + `)); + + 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} + .modelValue="${{ value: 'female', checked: true }}" + > + <${childTag} + .modelValue="${{ value: 'other', checked: false }}" + > + + `)); + + 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} + .modelValue=${{ value: 'female', checked: true }} + > + <${childTag} .choiceValue=${'other'}> + + `)); + + 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} + .choiceValue=${{ subObject: 'satisfies required' }} + > + + `)); + 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} .choiceValue=${'female'}> + + `)); + 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} .choiceValue=${'female'}> + + `)); + + 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} .choiceValue=${'female'} checked> + <${childTag} .choiceValue=${'other'}> + + `)); + + 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} .choiceValue=${'female'} checked> + <${childTag} .choiceValue=${'other'}> + + `)); + + 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} .choiceValue=${'female'} checked> + <${childTag} .choiceValue=${'other'}> + + `)); + + 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} .choiceValue=${'female'}> + <${childTag} .choiceValue=${'other'}> + + `)); + + 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} .choiceValue=${'female'}> + <${childTag} .choiceValue=${'other'} checked> + + `)); + + 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` + + <${parentTag} name="gender"> + <${childTag} .choiceValue=${'male'} checked disabled> + <${childTag} .choiceValue=${'female'} checked> + <${childTag} .choiceValue=${'other'}> + + + `)); + + 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'], + }); + }); + }); + }); +} diff --git a/packages/form-core/test/FormControlMixin.test.js b/packages/form-core/test/FormControlMixin.test.js index 190bdc7fb..b2c36ae8e 100644 --- a/packages/form-core/test/FormControlMixin.test.js +++ b/packages/form-core/test/FormControlMixin.test.js @@ -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} `)); const wrapper = /** @type {LitElement} */ (await fixture(`
`)); - 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}
Added to see attributes
- `); + `)); 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} + + `)); + 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( diff --git a/packages/form-core/test/choice-group/ChoiceGroupMixin.test.js b/packages/form-core/test/choice-group/ChoiceGroupMixin.test.js index 95fd8eb55..595e8d504 100644 --- a/packages/form-core/test/choice-group/ChoiceGroupMixin.test.js +++ b/packages/form-core/test/choice-group/ChoiceGroupMixin.test.js @@ -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` - - - - - - `)); - 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` - - - - - - `)); - 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` - - - - - - `)); - const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html` - - `)); - - 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` - - - - - `)); - - expect(el.formElements[0].name).to.equal('gender'); - expect(el.formElements[1].name).to.equal('gender'); - - const validChild = /** @type {ChoiceGroup} */ (await fixture(html` - - `)); - 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` - - - - - `)); - - const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html` - - `)); - - 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` - - - - - - `)); - - 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` - - - - - - `)); - - 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` - - - - - - `)); - - 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` - - - - - `)); - - 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` - - - - - `)); - - 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` - - - - - - `)); - - 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` - { - counter += 1; - }} - > - - - - - `)); - - 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` - - - - - `)); - 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` - - - - - `)); - 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` - - - - - `)); - - 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` - - - - - - `)); - - 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` - - - - - - `)); - - 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` - - - - - - `)); - - 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` - - - - - - `)); - - 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` - - - - - - `)); - - 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` - - - - - - - - `)); - - 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(); diff --git a/packages/form-core/types/FormControlMixinTypes.d.ts b/packages/form-core/types/FormControlMixinTypes.d.ts index 0c2bd205f..f3214e3ac 100644 --- a/packages/form-core/types/FormControlMixinTypes.d.ts +++ b/packages/form-core/types/FormControlMixinTypes.d.ts @@ -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( diff --git a/packages/form-integrations/docs/15-features-overview.md b/packages/form-integrations/docs/15-features-overview.md index 327cc1372..7f03d30ef 100644 --- a/packages/form-integrations/docs/15-features-overview.md +++ b/packages/form-integrations/docs/15-features-overview.md @@ -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 = () => { -
- Max. 5 guests -
+
Max. 5 guests
diff --git a/packages/form-integrations/docs/60-dialog-integration.md b/packages/form-integrations/docs/60-dialog-integration.md index 356256b1f..cbbf058b7 100644 --- a/packages/form-integrations/docs/60-dialog-integration.md +++ b/packages/form-integrations/docs/60-dialog-integration.md @@ -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', diff --git a/packages/listbox/README.md b/packages/listbox/README.md index 2252e1c7c..b1dbe0d8f 100644 --- a/packages/listbox/README.md +++ b/packages/listbox/README.md @@ -23,7 +23,7 @@ export default { export const main = () => html` Apple - Artichoke + Artichoke Asparagus Banana Beets @@ -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` `; ``` + +## 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` + + Apple + Artichoke + Asparagus + Banana + Beets + Bell pepper + Broccoli + Brussels sprout + Cabbage + Carrot + +`; +``` diff --git a/packages/listbox/src/LionOption.js b/packages/listbox/src/LionOption.js index 11d4f3ec6..02b0e2c8e 100644 --- a/packages/listbox/src/LionOption.js +++ b/packages/listbox/src/LionOption.js @@ -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 })); } } diff --git a/packages/listbox/src/LionOptions.js b/packages/listbox/src/LionOptions.js index 120967268..7ee1f3157 100644 --- a/packages/listbox/src/LionOptions.js +++ b/packages/listbox/src/LionOptions.js @@ -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; } diff --git a/packages/listbox/src/ListboxMixin.js b/packages/listbox/src/ListboxMixin.js index 6035eccc2..e969e094e 100644 --- a/packages/listbox/src/ListboxMixin.js +++ b/packages/listbox/src/ListboxMixin.js @@ -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` +
+ + +
+ `; + } + 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` -
- - -
- `; - } - - /** - * 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(); } }; diff --git a/packages/listbox/test-suites/ListboxMixin.suite.js b/packages/listbox/test-suites/ListboxMixin.suite.js index 7588de250..1b6744adc 100644 --- a/packages/listbox/test-suites/ListboxMixin.suite.js +++ b/packages/listbox/test-suites/ListboxMixin.suite.js @@ -1,10 +1,15 @@ import { Required } from '@lion/form-core'; import { expect, html, fixture, unsafeStatic } from '@open-wc/testing'; - -import '@lion/core/src/differentKeyEventNamesShimIE.js'; +import { LionOptions } from '@lion/listbox'; import '@lion/listbox/lion-option.js'; import '@lion/listbox/lion-options.js'; import '../lion-listbox.js'; +import '@lion/core/src/differentKeyEventNamesShimIE.js'; + +/** + * @typedef {import('@lion/combobox/src/LionCombobox').LionCombobox} LionCombobox + * @typedef {import('../src/LionListbox').LionListbox} LionListbox + */ /** * @param { {tagString:string, optionTagString:string} } [customConfig] @@ -20,242 +25,199 @@ export function runListboxMixinSuite(customConfig = {}) { const optionTag = unsafeStatic(cfg.optionTagString); describe('ListboxMixin', () => { - it('has a single modelValue representing the currently checked option', async () => { - const el = await fixture(html` + // TODO: See if it is possible to get functionality from ChoiceGroup and/or Field suite(s) + describe('FormControl (Field / ChoiceGroup) functionality', () => { + it('has a single modelValue representing the currently checked option', async () => { + const el = await fixture(html` <${tag} name="foo"> - - <${optionTag} .choiceValue=${10} checked>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - + <${optionTag} .choiceValue=${'10'} checked>Item 1 + <${optionTag} .choiceValue=${'20'}>Item 2 `); - expect(el.modelValue).to.equal(10); - }); + expect(el.modelValue).to.equal('10'); + }); - it('automatically sets the name attribute of child checkboxes to its own name', async () => { - const el = await fixture(html` + it('automatically sets the name attribute of child checkboxes to its own name', async () => { + const el = await fixture(html` <${tag} name="foo"> - - <${optionTag} .choiceValue=${10} checked>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - + <${optionTag} .choiceValue=${10} checked>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 `); - expect(el.formElements[0].name).to.equal('foo'); - expect(el.formElements[1].name).to.equal('foo'); + expect(el.formElements[0].name).to.equal('foo'); + expect(el.formElements[1].name).to.equal('foo'); - const validChild = await fixture( - html` <${optionTag} .choiceValue=${30}>Item 3 `, - ); - el.appendChild(validChild); + const validChild = await fixture( + html` <${optionTag} .choiceValue=${30}>Item 3 `, + ); + el.appendChild(validChild); - expect(el.formElements[2].name).to.equal('foo'); - }); + expect(el.formElements[2].name).to.equal('foo'); + }); - it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => { - const el = await fixture(html` + it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => { + const el = await fixture(html` <${tag} name="foo"> - - <${optionTag} .choiceValue=${10} checked>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - + <${optionTag} .choiceValue=${10} checked>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 `); - const invalidChild = await fixture( - html` <${optionTag} .modelValue=${'Lara'}> `, - ); + const invalidChild = await fixture( + html` <${optionTag} .modelValue=${'Lara'}> `, + ); - expect(() => { - el.addFormElement(invalidChild); - }).to.throw( - `The ${cfg.tagString} name="foo" does not allow to register lion-option with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }`, - ); - }); + expect(() => { + el.addFormElement(invalidChild); + }).to.throw( + `The ${cfg.tagString} name="foo" does not allow to register lion-option with .modelValue="Lara" - The modelValue should represent an Object { value: "foo", checked: false }`, + ); + }); - it('throws if a child element with a different name than the group tries to register', async () => { - const el = await fixture(html` + it('throws if a child element with a different name than the group tries to register', async () => { + const el = await fixture(html` <${tag} name="gender"> - - <${optionTag} .choiceValue=${'female'} checked> - <${optionTag} .choiceValue=${'other'}> - + <${optionTag} .choiceValue=${'female'} checked> + <${optionTag} .choiceValue=${'other'}> `); - const invalidChild = await fixture(html` + const invalidChild = await fixture(html` <${optionTag} name="foo" .choiceValue=${'male'}> `); - expect(() => { - el.addFormElement(invalidChild); - }).to.throw( - `The ${cfg.tagString} name="gender" does not allow to register lion-option with custom names (name="foo" given)`, - ); - }); + expect(() => { + el.addFormElement(invalidChild); + }).to.throw( + `The ${cfg.tagString} name="gender" does not allow to register lion-option with custom names (name="foo" given)`, + ); + }); - it('can set initial modelValue on creation', async () => { - const el = await fixture(html` + it('can set initial modelValue on creation', async () => { + const el = await fixture(html` <${tag} name="gender" .modelValue=${'other'}> - - <${optionTag} .choiceValue=${'male'}> - <${optionTag} .choiceValue=${'female'}> - <${optionTag} .choiceValue=${'other'}> - + <${optionTag} .choiceValue=${'male'}> + <${optionTag} .choiceValue=${'female'}> + <${optionTag} .choiceValue=${'other'}> `); - expect(el.modelValue).to.equal('other'); - expect(el.formElements[2].checked).to.be.true; - }); + expect(el.modelValue).to.equal('other'); + expect(el.formElements[2].checked).to.be.true; + }); - it(`has a fieldName based on the label`, async () => { - const el1 = await fixture(html` - <${tag} label="foo"> + it(`has a fieldName based on the label`, async () => { + const el1 = await fixture(html` + <${tag} label="foo"> `); - expect(el1.fieldName).to.equal(el1._labelNode.textContent); + expect(el1.fieldName).to.equal(el1._labelNode.textContent); - const el2 = await fixture(html` + const el2 = await fixture(html` <${tag}> - + `); - expect(el2.fieldName).to.equal(el2._labelNode.textContent); - }); + expect(el2.fieldName).to.equal(el2._labelNode.textContent); + }); - it(`has a fieldName based on the name if no label exists`, async () => { - const el = await fixture(html` - <${tag} name="foo"> + it(`has a fieldName based on the name if no label exists`, async () => { + const el = await fixture(html` + <${tag} name="foo"> `); - expect(el.fieldName).to.equal(el.name); - }); + expect(el.fieldName).to.equal(el.name); + }); - it(`can override fieldName`, async () => { - const el = await fixture(html` - <${tag} label="foo" .fieldName="${'bar'}" - > + it(`can override fieldName`, async () => { + const el = await fixture(html` + <${tag} label="foo" .fieldName="${'bar'}"> `); - expect(el.__fieldName).to.equal(el.fieldName); - }); + expect(el.__fieldName).to.equal(el.fieldName); + }); - it('does not have a tabindex', async () => { - const el = await fixture(html` - <${tag}> - - - `); - expect(el.hasAttribute('tabindex')).to.be.false; - }); - - it('delegates the name attribute to its children options', async () => { - const el = await fixture(html` - <${tag} name="foo"> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - - const optOne = el.querySelectorAll('lion-option')[0]; - const optTwo = el.querySelectorAll('lion-option')[1]; - - expect(optOne.name).to.equal('foo'); - expect(optTwo.name).to.equal('foo'); - }); - - it('supports validation', async () => { - const el = await fixture(html` + it('supports validation', async () => { + const el = await fixture(html` <${tag} id="color" name="color" label="Favorite color" .validators="${[new Required()]}" > - - <${optionTag} .choiceValue=${null}>select a color - <${optionTag} .choiceValue=${'red'}>Red - <${optionTag} .choiceValue=${'hotpink'} disabled>Hotpink - <${optionTag} .choiceValue=${'teal'}>Teal - + <${optionTag} .choiceValue=${null}>select a color + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'hotpink'} disabled>Hotpink + <${optionTag} .choiceValue=${'teal'}>Teal `); - expect(el.hasFeedbackFor.includes('error')).to.be.true; - expect(el.showsFeedbackFor.includes('error')).to.be.false; + expect(el.hasFeedbackFor.includes('error')).to.be.true; + expect(el.showsFeedbackFor.includes('error')).to.be.false; - // test submitted prop explicitly, since we dont extend field, we add the prop manually - el.submitted = true; - await el.updateComplete; - expect(el.showsFeedbackFor.includes('error')).to.be.true; + // test submitted prop explicitly, since we dont extend field, we add the prop manually + el.submitted = true; + await el.updateComplete; + expect(el.showsFeedbackFor.includes('error')).to.be.true; - el._listboxNode.children[1].checked = true; - await el.updateComplete; - expect(el.hasFeedbackFor.includes('error')).to.be.false; - expect(el.showsFeedbackFor.includes('error')).to.be.false; + el._listboxNode.children[1].checked = true; + await el.updateComplete; + expect(el.hasFeedbackFor.includes('error')).to.be.false; + expect(el.showsFeedbackFor.includes('error')).to.be.false; - el._listboxNode.children[0].checked = true; - await el.updateComplete; - expect(el.hasFeedbackFor.includes('error')).to.be.true; - expect(el.showsFeedbackFor.includes('error')).to.be.true; + el._listboxNode.children[0].checked = true; + await el.updateComplete; + expect(el.hasFeedbackFor.includes('error')).to.be.true; + expect(el.showsFeedbackFor.includes('error')).to.be.true; + }); }); - it('supports having no default selection initially', async () => { - const el = await fixture(html` + describe('Selection', () => { + it('supports having no default selection initially', async () => { + const el = await fixture(html` <${tag} id="color" name="color" label="Favorite color" has-no-default-selected> - - <${optionTag} .choiceValue=${'red'}>Red - <${optionTag} .choiceValue=${'hotpink'}>Hotpink - <${optionTag} .choiceValue=${'teal'}>Teal - + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'hotpink'}>Hotpink + <${optionTag} .choiceValue=${'teal'}>Teal `); - expect(el.selectedElement).to.be.undefined; - expect(el.modelValue).to.equal(''); - }); + expect(el.selectedElement).to.be.undefined; + expect(el.modelValue).to.equal(''); + }); - it('supports changing the selection through serializedValue setter', async () => { - const el = await fixture(html` - <${tag} id="color" name="color" label="Favorite color"> - - <${optionTag} .choiceValue=${'red'}>Red - <${optionTag} .choiceValue=${'hotpink'}>Hotpink - <${optionTag} .choiceValue=${'teal'}>Teal - + it('supports changing the selection through serializedValue setter', async () => { + const el = await fixture(html` + <${tag} id="color" name="color" label="Favorite color" has-no-default-selected> + <${optionTag} .choiceValue=${'red'}>Red + <${optionTag} .choiceValue=${'hotpink'}>Hotpink + <${optionTag} .choiceValue=${'teal'}>Teal `); - expect(el.checkedIndex).to.equal(0); - expect(el.serializedValue).to.equal('red'); + expect(el.checkedIndex).to.equal(-1); + expect(el.serializedValue).to.equal(''); - el.serializedValue = 'hotpink'; + el.serializedValue = 'hotpink'; - expect(el.checkedIndex).to.equal(1); - expect(el.serializedValue).to.equal('hotpink'); + expect(el.checkedIndex).to.equal(1); + expect(el.serializedValue).to.equal('hotpink'); + }); }); describe('Accessibility', () => { - it('is accessible when closed', async () => { + it('[axe]: is accessible when closed', async () => { const el = await fixture(html` <${tag} label="age"> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 `); await expect(el).to.be.accessible(); }); - it('is accessible when opened', async () => { + it('[axe]: is accessible when opened', async () => { const el = await fixture(html` <${tag} label="age"> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 `); el.opened = true; @@ -264,9 +226,676 @@ export function runListboxMixinSuite(customConfig = {}) { await expect(el).to.be.accessible(); }); + + it('does not have a tabindex', async () => { + const el = await fixture(html`<${tag}>`); + expect(el.hasAttribute('tabindex')).to.be.false; + }); + + it('creates unique ids for all children', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20} selected>Item 2 + <${optionTag} .choiceValue=${30} id="predefined">Item 3 + + `); + expect(el.querySelectorAll('lion-option')[0].id).to.exist; + expect(el.querySelectorAll('lion-option')[1].id).to.exist; + expect(el.querySelectorAll('lion-option')[2].id).to.equal('predefined'); + }); + + it('has a reference to the active option', async () => { + const el = await fixture(html` + <${tag} opened has-no-default-selected autocomplete="list"> + <${optionTag} .choiceValue=${'10'} id="first">Item 1 + <${optionTag} .choiceValue=${'20'} checked id="second">Item 2 + + `); + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.be.null; + await el.updateComplete; + + // Normalize + el.activeIndex = 0; + + // el._activeDescendantOwnerNode.dispatchEvent( + // new KeyboardEvent('keydown', { key: 'ArrowDown' }), + // ); + await el.updateComplete; + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( + 'first', + ); + el._activeDescendantOwnerNode.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown' }), + ); + await el.updateComplete; + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( + 'second', + ); + }); + + it('puts "aria-setsize" on all options to indicate the total amount of options', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + <${optionTag} .choiceValue=${30}>Item 3 + + `); + const optionEls = [].slice.call(el.querySelectorAll('lion-option')); + optionEls.forEach(optionEl => { + expect(optionEl.getAttribute('aria-setsize')).to.equal('3'); + }); + }); + + it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + <${optionTag} .choiceValue=${30}>Item 3 + + `); + const optionEls = [].slice.call(el.querySelectorAll('lion-option')); + optionEls.forEach((oEl, i) => { + expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`); + }); + }); }); - describe('Use cases', () => { + describe('Values', () => { + // TODO: ChoiceGroup suite? + it('registers options', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); + expect(el.formElements.length).to.equal(2); + expect(el.formElements).to.eql([ + el.querySelectorAll('lion-option')[0], + el.querySelectorAll('lion-option')[1], + ]); + }); + + it('allows null choiceValue', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} checked .choiceValue=${null}>Please select value + <${optionTag} .choiceValue=${20}>Item 2 + + `); + + if (el._comboboxNode) { + // note that the modelValue can only be supplied as string if we have a textbox + // (parsers not supported atm) + expect(el.modelValue).to.equal(''); + return; + } + expect(el.modelValue).to.be.null; + }); + + it('has an activeIndex', async () => { + const el = await fixture(html` + <${tag} has-no-default-selected autocomplete="list"> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); + expect(el.activeIndex).to.equal(-1); + + el.querySelectorAll('lion-option')[1].active = true; + expect(el.querySelectorAll('lion-option')[0].active).to.be.false; + expect(el.activeIndex).to.equal(1); + }); + }); + + describe('Interactions', () => { + describe('Keyboard navigation', () => { + describe('Rotate Keyboard Navigation', () => { + it('stops navigation by default at end of option list', async () => { + const el = /** @type {LionListbox} */ (await fixture(html` + <${tag} opened name="foo" .rotateKeyboardNavigation="${false}"> + <${optionTag} .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Bla'}">Bla + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + // Normalize + el.activeIndex = 0; + const options = el.formElements; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + expect(options[0].active).to.be.true; + expect(options[1].active).to.be.false; + expect(options[2].active).to.be.false; + el.activeIndex = 2; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(options[0].active).to.be.false; + expect(options[1].active).to.be.false; + expect(options[2].active).to.be.true; + }); + + it('when "rotate-navigation" provided, selects first option after navigated to next from last and vice versa', async () => { + const el = /** @type {LionListbox} */ (await fixture(html` + <${tag} opened name="foo" rotate-keyboard-navigation autocomplete="inline"> + <${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Bla'}">Bla + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + + el._inputNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + // Normalize + el.activeIndex = 0; + expect(el.activeIndex).to.equal(0); + + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + await el.updateComplete; + expect(el.activeIndex).to.equal(2); + + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(el.activeIndex).to.equal(0); + // Extra check: regular navigation + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(el.activeIndex).to.equal(1); + }); + }); + + describe('Enter', () => { + it('[Enter] selects active option', async () => { + const el = /** @type {LionListbox} */ (await fixture(html` + <${tag} opened name="foo" autocomplete="none"> + <${optionTag} .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Bla'}">Bla + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + // Normalize suite + el.activeIndex = 0; + const options = el.formElements; + el.checkedIndex = 0; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(options[1].checked).to.be.true; + }); + }); + describe('Space', () => { + it('selects active option when "_listboxReceivesNoFocus" is true', async () => { + // When listbox is not focusable (in case of a combobox), the user should be allowed + // to enter a space in the focusable element (texbox) + const el = /** @type {LionListbox} */ (await fixture(html` + <${tag} opened name="foo" ._listboxReceivesNoFocus="${false}" autocomplete="none"> + <${optionTag} .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Bla'}">Bla + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + // Normalize suite + el.activeIndex = 0; + const options = el.formElements; + el.checkedIndex = 0; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + expect(options[1].checked).to.be.true; + el.checkedIndex = 0; + el._listboxReceivesNoFocus = true; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + expect(options[1].checked).to.be.false; + }); + }); + // TODO: add key combinations like shift+home/ctrl+A etc etc. + // TODO: nice to have. Get from menu impl. + it.skip('selects a value with single [character] key', async () => { + const el = await fixture(html` + <${tag} opened> + <${optionTag} .choiceValue=${'a'}>A + <${optionTag} .choiceValue=${'b'}>B + <${optionTag} .choiceValue=${'c'}>C + + `); + expect(el.choiceValue).to.equal('a'); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' })); + expect(el.choiceValue).to.equal('c'); + }); + it.skip('selects a value with multiple [character] keys', async () => { + const el = await fixture(html` + <${tag} opened> + <${optionTag} .choiceValue=${'bar'}>Bar + <${optionTag} .choiceValue=${'far'}>Far + <${optionTag} .choiceValue=${'foo'}>Foo + + `); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F' })); + expect(el.choiceValue).to.equal('far'); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' })); + expect(el.choiceValue).to.equal('foo'); + }); + it('navigates to first and last option with [Home] and [End] keys', async () => { + const el = await fixture(html` + <${tag} opened> + <${optionTag} .choiceValue=${'10'}>Item 1 + <${optionTag} .choiceValue=${'20'}>Item 2 + <${optionTag} .choiceValue=${'30'} checked>Item 3 + <${optionTag} .choiceValue=${'40'}>Item 4 + + `); + + if (el._listboxReceivesNoFocus) { + return; + } + + el.activeIndex = 2; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); + expect(el.activeIndex).to.equal(0); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' })); + expect(el.activeIndex).to.equal(3); + }); + it('navigates through open lists with [ArrowDown] [ArrowUp] keys activates the option', async () => { + const el = await fixture(html` + <${tag} opened has-no-default-selected> + <${optionTag} .choiceValue=${'Item 1'}>Item 1 + <${optionTag} .choiceValue=${'Item 2'}>Item 2 + <${optionTag} .choiceValue=${'Item 3'}>Item 3 + + `); + // Normalize across listbox/select-rich/combobox + el.activeIndex = 0; + // selectionFollowsFocus will be true by default on combobox (running this suite), + // but should still be able to work with selectionFollowsFocus=false + el.selectionFollowsFocus = false; + expect(el.activeIndex).to.equal(0); + expect(el.checkedIndex).to.equal(-1); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(el.activeIndex).to.equal(1); + expect(el.checkedIndex).to.equal(-1); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + expect(el.activeIndex).to.equal(0); + expect(el.checkedIndex).to.equal(-1); + }); + }); + + describe('Orientation', () => { + it('has a default value of "vertical"', async () => { + const el = /** @type {Listbox} */ (await fixture(html` + <${tag} opened name="foo" autocomplete="list"> + <${optionTag} .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + expect(el.orientation).to.equal('vertical'); + const options = el.formElements; + // Normalize for suite tests + el.activeIndex = 0; + + await el.updateComplete; + expect(options[0].active).to.be.true; + expect(options[1].active).to.be.false; + + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(options[0].active).to.be.false; + expect(options[1].active).to.be.true; + + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + expect(options[0].active).to.be.true; + expect(options[1].active).to.be.false; + + // No response to horizontal arrows... + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + expect(options[0].active).to.be.true; + expect(options[1].active).to.be.false; + + el.activeIndex = 1; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + expect(options[0].active).to.be.false; + expect(options[1].active).to.be.true; + }); + + it('uses [ArrowLeft] and [ArrowRight] keys when "horizontal"', async () => { + const el = /** @type {Listbox} */ (await fixture(html` + <${tag} opened name="foo" orientation="horizontal" autocomplete="list"> + <${optionTag} .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + expect(el.orientation).to.equal('horizontal'); + + // Normalize for suite tests + el.activeIndex = 0; + + await el.updateComplete; + + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + expect(el.activeIndex).to.equal(1); + + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + expect(el.activeIndex).to.equal(0); + + // No response to vertical arrows... + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(el.activeIndex).to.equal(0); + + el.activeIndex = 1; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + expect(el.activeIndex).to.equal(1); + }); + + describe('Accessibility', () => { + it('adds aria-orientation attribute to listbox node', async () => { + const el = /** @type {Listbox} */ (await fixture(html` + <${tag} name="foo" orientation="horizontal"> + <${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + expect(el._listboxNode.getAttribute('aria-orientation')).to.equal('horizontal'); + }); + }); + }); + + describe('Multiple Choice', () => { + it('does not uncheck siblings', async () => { + const el = /** @type {Listbox} */ (await fixture(html` + <${tag} name="foo" multiple-choice> + <${optionTag} .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Chard'}">Chard + <${optionTag} .choiceValue="${'Chicory'}">Chicory + <${optionTag} .choiceValue="${'Victoria Plum'}">Victoria Plum + + `)); + const options = el.formElements; + options[0].checked = true; + options[1].checked = true; + expect(options[0].checked).to.equal(true); + expect(el.modelValue).to.eql(['Artichoke', 'Chard']); + }); + + describe('Accessibility', () => { + it('adds aria-multiselectable="true" to listbox node', async () => { + const el = /** @type {Listbox} */ (await fixture(html` + <${tag} name="foo" multiple-choice> + <${optionTag} .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + expect(el._listboxNode.getAttribute('aria-multiselectable')).to.equal('true'); + }); + + it('does not allow "selectionFollowsFocus"', async () => { + const el = /** @type {Listbox} */ (await fixture(html` + <${tag} name="foo" multiple-choice> + <${optionTag} checked .choiceValue="${'Artichoke'}">Artichoke + <${optionTag} .choiceValue="${'Chard'}">Chard + + `)); + el._inputNode.focus(); + el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + expect(el._listboxNode.getAttribute('aria-multiselectable')).to.equal('true'); + }); + }); + }); + + describe('Selection Follows Focus', () => { + 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` + <${tag} opened selection-follows-focus> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + <${optionTag} .choiceValue=${30}>Item 3 + + `); + const options = Array.from(el.querySelectorAll('lion-option')); + // Normalize start values between listbox, slect and combobox and test interaction below + el.activeIndex = 0; + el.checkedIndex = 0; + 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); + }); + it('checks first and last option with [Home] and [End] keys', async () => { + const el = await fixture(html` + <${tag} opened selection-follows-focus> + <${optionTag} .choiceValue=${'10'}>Item 1 + <${optionTag} .choiceValue=${'20'}>Item 2 + <${optionTag} .choiceValue=${'30'} checked>Item 3 + <${optionTag} .choiceValue=${'40'}>Item 4 + + `); + + if (el._listboxReceivesNoFocus) { + return; + } + + expect(el.modelValue).to.equal('30'); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); + expect(el.modelValue).to.equal('10'); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' })); + expect(el.modelValue).to.equal('40'); + }); + }); + + describe('Disabled Host', () => { + it('cannot be navigated with keyboard if disabled', async () => { + const el = await fixture(html` + <${tag} disabled> + <${optionTag} .choiceValue=${'10'}>Item 1 + <${optionTag} checked .choiceValue=${'20'}>Item 2 + + `); + await el.updateComplete; + const { checkedIndex } = el; + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(el.checkedIndex).to.equal(checkedIndex); + }); + + it('sync its disabled state to all options', async () => { + const el = await fixture(html` + <${tag} opened> + <${optionTag} .choiceValue=${10} disabled>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); + const options = [...el.querySelectorAll('lion-option')]; + el.disabled = true; + await el.updateComplete; + expect(options[0].disabled).to.be.true; + expect(options[1].disabled).to.be.true; + + el.disabled = false; + await el.updateComplete; + expect(options[0].disabled).to.be.true; + expect(options[1].disabled).to.be.false; + }); + + it('can be enabled (incl. its options) even if it starts as disabled', async () => { + const el = await fixture(html` + <${tag} disabled> + <${optionTag} .choiceValue=${10} disabled>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); + const options = [...el.querySelectorAll('lion-option')]; + expect(options[0].disabled).to.be.true; + expect(options[1].disabled).to.be.true; + + el.disabled = false; + await el.updateComplete; + expect(options[0].disabled).to.be.true; + expect(options[1].disabled).to.be.false; + }); + }); + + describe('Disabled Options', () => { + it('does not skip disabled options but prevents checking them', async () => { + const el = await fixture(html` + <${tag} opened autocomplete="inline" .selectionFollowsFocus="${false}"> + <${optionTag} .choiceValue=${'Item 1'} checked>Item 1 + <${optionTag} .choiceValue=${'Item 2'} disabled>Item 2 + <${optionTag} .choiceValue=${'Item 3'}>Item 3 + + `); + + // Normalize activeIndex across multiple implementers of ListboxMixinSuite + el.activeIndex = 0; + + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(el.activeIndex).to.equal(1); + + expect(el.checkedIndex).to.equal(0); + el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + // Checked index stays where it was + expect(el.checkedIndex).to.equal(0); + }); + }); + + describe('Programmatic interaction', () => { + it('can set active state', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20} id="myId">Item 2 + + `); + const opt = el.querySelectorAll('lion-option')[1]; + opt.active = true; + expect(el._activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal( + 'myId', + ); + }); + + it('can set checked state', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); + const option = el.querySelectorAll('lion-option')[1]; + option.checked = true; + expect(el.modelValue).to.equal(20); + }); + + it('does not allow to set checkedIndex or activeIndex to be out of bound', async () => { + const el = await fixture(html` + <${tag} has-no-default-selected autocomplete="list"> + <${optionTag} .choiceValue=${10}>Item 1 + + `); + expect(() => { + el.activeIndex = -1; + el.activeIndex = 1; + el.checkedIndex = -1; + el.checkedIndex = 1; + }).to.not.throw(); + expect(el.checkedIndex).to.equal(-1); + expect(el.activeIndex).to.equal(-1); + }); + + it('unsets checked on other options when option becomes checked', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); + const options = el.querySelectorAll('lion-option'); + options[0].checked = true; + expect(options[0].checked).to.be.true; + expect(options[1].checked).to.be.false; + options[1].checked = true; + expect(options[0].checked).to.be.false; + expect(options[1].checked).to.be.true; + }); + + it('unsets active on other options when option becomes active', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} active .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); + const options = el.querySelectorAll('lion-option'); + expect(options[0].active).to.be.true; + options[1].active = true; + expect(options[0].active).to.be.false; + }); + }); + + // TODO: ChoiceGroup suite? + describe('Interaction states', () => { + it('becomes dirty if value changed once', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${10}>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 + + `); + + expect(el.dirty).to.be.false; + el.modelValue = 20; + expect(el.dirty).to.be.true; + }); + + it('is prefilled if there is a value on init', async () => { + const el = await fixture(html` + <${tag}> + <${optionTag} checked .choiceValue=${'10'}>Item 1 + + `); + expect(el.prefilled).to.be.true; + + const elEmpty = await fixture(html` + <${tag}> + <${optionTag} .choiceValue=${null}>Please select a value + <${optionTag} .choiceValue=${'10'}>Item 1 + + `); + expect(elEmpty.prefilled).to.be.false; + }); + }); + + // TODO: ChoiceGroup suite? + describe('Validation', () => { + it('can be required', async () => { + const el = await fixture(html` + <${tag} .validators=${[new Required()]}> + <${optionTag} .choiceValue=${null}>Please select a value + <${optionTag} .choiceValue=${20}>Item 2 + + `); + + expect(el.hasFeedbackFor).to.include('error'); + expect(el.validationStates).to.have.a.property('error'); + expect(el.validationStates.error).to.have.a.property('Required'); + + el.modelValue = 20; + expect(el.hasFeedbackFor).not.to.include('error'); + expect(el.validationStates).to.have.a.property('error'); + expect(el.validationStates.error).not.to.have.a.property('Required'); + }); + }); + }); + + describe('Complex Data', () => { it('works for complex array data', async () => { const objs = [ { type: 'mastercard', label: 'Master Card', amount: 12000, active: true }, @@ -274,17 +903,16 @@ export function runListboxMixinSuite(customConfig = {}) { ]; const el = await fixture(html` <${tag} label="Favorite color" name="color"> - - ${objs.map( - obj => html` - <${optionTag} .modelValue=${{ value: obj, checked: false }} - >${obj.label} - `, - )} - + ${objs.map( + obj => html` + <${optionTag} .modelValue=${{ value: obj, checked: false }} + >${obj.label} + `, + )} `); + el.checkedIndex = 0; expect(el.modelValue).to.deep.equal({ type: 'mastercard', label: 'Master Card', @@ -307,10 +935,10 @@ export function runListboxMixinSuite(customConfig = {}) { let properlyInstantiated = false; try { - const el = document.createElement('lion-listbox'); + const el = document.createElement(cfg.tagString); const optionsEl = document.createElement('lion-options'); optionsEl.slot = 'input'; - const optionEl = document.createElement('lion-option'); + const optionEl = document.createElement(cfg.optionTagString); optionsEl.appendChild(optionEl); el.appendChild(optionsEl); properlyInstantiated = true; @@ -320,479 +948,22 @@ export function runListboxMixinSuite(customConfig = {}) { expect(properlyInstantiated).to.be.true; }); - }); - }); - describe('lion-listbox interactions', () => { - describe('values', () => { - it('registers options', async () => { + it('can be instantiated without options', async () => { const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - + <${tag} name="foo"> + <${optionTag} .choiceValue=${10} checked>Item 1 + <${optionTag} .choiceValue=${20}>Item 2 `); + + expect(el._listboxNode).to.exist; + expect(el._listboxNode).to.be.instanceOf(LionOptions); + expect(el.querySelector('[role=listbox]')).to.equal(el._listboxNode); + expect(el.formElements.length).to.equal(2); - expect(el.formElements).to.eql([ - el.querySelectorAll('lion-option')[0], - el.querySelectorAll('lion-option')[1], - ]); - }); - - it('has the first element by default checked and active', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - - expect(el.querySelector('lion-option').checked).to.be.true; - expect(el.querySelector('lion-option').active).to.be.true; - expect(el.modelValue).to.equal(10); - - expect(el.checkedIndex).to.equal(0); - expect(el.activeIndex).to.equal(0); - }); - - it('allows null choiceValue', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${null}>Please select value - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - expect(el.modelValue).to.be.null; - }); - - it('has the checked option as modelValue', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20} checked>Item 2 - - - `); - expect(el.modelValue).to.equal(20); - }); - - it('has an activeIndex', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - expect(el.activeIndex).to.equal(0); - - el.querySelectorAll('lion-option')[1].active = true; - expect(el.querySelectorAll('lion-option')[0].active).to.be.false; - expect(el.activeIndex).to.equal(1); - }); - }); - - describe('Keyboard navigation', () => { - it('does not allow to navigate above the first or below the last option', async () => { - const el = await fixture(html` - <${tag} opened> - - <${optionTag} .choiceValue=${10}>Item 1 - - - `); - expect(() => { - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); - }).to.not.throw(); - expect(el.checkedIndex).to.equal(0); - expect(el.activeIndex).to.equal(0); - }); - - // TODO: nice to have - it.skip('selects a value with single [character] key', async () => { - const el = await fixture(html` - <${tag} opened> - - <${optionTag} .choiceValue=${'a'}>A - <${optionTag} .choiceValue=${'b'}>B - <${optionTag} .choiceValue=${'c'}>C - - - `); - expect(el.choiceValue).to.equal('a'); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' })); - expect(el.choiceValue).to.equal('c'); - }); - - it.skip('selects a value with multiple [character] keys', async () => { - const el = await fixture(html` - <${tag} opened> - - <${optionTag} .choiceValue=${'bar'}>Bar - <${optionTag} .choiceValue=${'far'}>Far - <${optionTag} .choiceValue=${'foo'}>Foo - - - `); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F' })); - expect(el.choiceValue).to.equal('far'); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' })); - expect(el.choiceValue).to.equal('foo'); - }); - }); - - describe('Keyboard navigation Mac', () => { - it('navigates through open list with [ArrowDown] [ArrowUp] keys activates the option', async () => { - const el = await fixture(html` - <${tag} opened interaction-mode="mac"> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - <${optionTag} .choiceValue=${30}>Item 3 - - - `); - expect(el.activeIndex).to.equal(0); - expect(el.checkedIndex).to.equal(0); - - el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); - expect(el.activeIndex).to.equal(1); - expect(el.checkedIndex).to.equal(0); - - el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); - expect(el.activeIndex).to.equal(0); - expect(el.checkedIndex).to.equal(0); - }); - }); - - describe('Disabled', () => { - it('still has a checked value', async () => { - const el = await fixture(html` - <${tag} disabled> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - - expect(el.modelValue).to.equal(10); - }); - - it('cannot be navigated with keyboard if disabled', async () => { - const el = await fixture(html` - <${tag} disabled> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); - expect(el.modelValue).to.equal(10); - }); - - it('skips disabled options while navigating through list with [ArrowDown] [ArrowUp] keys', async () => { - const el = await fixture(html` - <${tag} opened> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20} disabled>Item 2 - <${optionTag} .choiceValue=${30}>Item 3 - - - `); - el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); - expect(el.activeIndex).to.equal(2); - - el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); - expect(el.activeIndex).to.equal(0); - }); - - // flaky test - it.skip('skips disabled options while navigates to first and last option with [Home] and [End] keys', async () => { - const el = await fixture(html` - <${tag} opened> - - <${optionTag} .choiceValue=${10} disabled>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - <${optionTag} .choiceValue=${30} checked>Item 3 - <${optionTag} .choiceValue=${40} disabled>Item 4 - - - `); - expect(el.activeIndex).to.equal(2); - - el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' })); - expect(el.activeIndex).to.equal(2); - - el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); - expect(el.activeIndex).to.equal(1); - }); - - it('checks the first enabled option', async () => { - const el = await fixture(html` - <${tag} opened> - - <${optionTag} .choiceValue=${10} disabled>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - <${optionTag} .choiceValue=${30}>Item 3 - - - `); - expect(el.activeIndex).to.equal(1); - expect(el.checkedIndex).to.equal(1); - }); - - it('sync its disabled state to all options', async () => { - const el = await fixture(html` - <${tag} opened> - - <${optionTag} .choiceValue=${10} disabled>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - const options = [...el.querySelectorAll('lion-option')]; - el.disabled = true; - await el.updateComplete; - expect(options[0].disabled).to.be.true; - expect(options[1].disabled).to.be.true; - - el.disabled = false; - await el.updateComplete; - expect(options[0].disabled).to.be.true; - expect(options[1].disabled).to.be.false; - }); - - it('can be enabled (incl. its options) even if it starts as disabled', async () => { - const el = await fixture(html` - <${tag} disabled> - - <${optionTag} .choiceValue=${10} disabled>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - const options = [...el.querySelectorAll('lion-option')]; - expect(options[0].disabled).to.be.true; - expect(options[1].disabled).to.be.true; - - el.disabled = false; - await el.updateComplete; - expect(options[0].disabled).to.be.true; - expect(options[1].disabled).to.be.false; - }); - }); - - describe('Programmatic interaction', () => { - it('can set active state', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20} id="myId">Item 2 - - - `); - const opt = el.querySelectorAll('lion-option')[1]; - opt.active = true; - expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('myId'); - }); - - it('can set checked state', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - const option = el.querySelectorAll('lion-option')[1]; - option.checked = true; - expect(el.modelValue).to.equal(20); - }); - - it('does not allow to set checkedIndex or activeIndex to be out of bound', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - - - `); - expect(() => { - el.activeIndex = -1; - el.activeIndex = 1; - el.checkedIndex = -1; - el.checkedIndex = 1; - }).to.not.throw(); - expect(el.checkedIndex).to.equal(0); - expect(el.activeIndex).to.equal(0); - }); - - it('unsets checked on other options when option becomes checked', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - const options = el.querySelectorAll('lion-option'); - expect(options[0].checked).to.be.true; - options[1].checked = true; - expect(options[0].checked).to.be.false; - }); - - it('unsets active on other options when option becomes active', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - const options = el.querySelectorAll('lion-option'); - expect(options[0].active).to.be.true; - options[1].active = true; - expect(options[0].active).to.be.false; - }); - }); - - describe('Interaction states', () => { - it('becomes dirty if value changed once', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - - expect(el.dirty).to.be.false; - el.modelValue = 20; - expect(el.dirty).to.be.true; - }); - - it('is prefilled if there is a value on init', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - - - `); - expect(el.prefilled).to.be.true; - - const elEmpty = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${null}>Please select a value - <${optionTag} .choiceValue=${10}>Item 1 - - - `); - expect(elEmpty.prefilled).to.be.false; - }); - }); - - describe('Validation', () => { - it('can be required', async () => { - const el = await fixture(html` - <${tag} .validators=${[new Required()]}> - - <${optionTag} .choiceValue=${null}>Please select a value - <${optionTag} .choiceValue=${20}>Item 2 - - - `); - - expect(el.hasFeedbackFor).to.include('error'); - expect(el.validationStates).to.have.a.property('error'); - expect(el.validationStates.error).to.have.a.property('Required'); - - el.modelValue = 20; - expect(el.hasFeedbackFor).not.to.include('error'); - expect(el.validationStates).to.have.a.property('error'); - expect(el.validationStates.error).not.to.have.a.property('Required'); - }); - }); - - describe('Accessibility', () => { - it('creates unique ids for all children', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20} selected>Item 2 - <${optionTag} .choiceValue=${30} id="predefined">Item 3 - - - `); - expect(el.querySelectorAll('lion-option')[0].id).to.exist; - expect(el.querySelectorAll('lion-option')[1].id).to.exist; - expect(el.querySelectorAll('lion-option')[2].id).to.equal('predefined'); - }); - - it('has a reference to the selected option', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10} id="first">Item 1 - <${optionTag} .choiceValue=${20} checked id="second">Item 2 - - - `); - - expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('first'); - el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); - expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('second'); - }); - - it('puts "aria-setsize" on all options to indicate the total amount of options', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - <${optionTag} .choiceValue=${30}>Item 3 - - - `); - const optionEls = [].slice.call(el.querySelectorAll('lion-option')); - optionEls.forEach(optionEl => { - expect(optionEl.getAttribute('aria-setsize')).to.equal('3'); - }); - }); - - it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => { - const el = await fixture(html` - <${tag}> - - <${optionTag} .choiceValue=${10}>Item 1 - <${optionTag} .choiceValue=${20}>Item 2 - <${optionTag} .choiceValue=${30}>Item 3 - - - `); - const optionEls = [].slice.call(el.querySelectorAll('lion-option')); - optionEls.forEach((oEl, i) => { - expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`); - }); + expect(el._listboxNode.children.length).to.equal(2); + expect(el._listboxNode.children[0].tagName).to.equal(cfg.optionTagString.toUpperCase()); }); }); }); diff --git a/packages/listbox/types/ListboxMixinTypes.d.ts b/packages/listbox/types/ListboxMixinTypes.d.ts index 4907e8740..dccc85336 100644 --- a/packages/listbox/types/ListboxMixinTypes.d.ts +++ b/packages/listbox/types/ListboxMixinTypes.d.ts @@ -55,7 +55,7 @@ export declare class ListboxHost { protected _listboxOnKeyUp(ev: KeyboardEvent): void; - protected _setupListboxNodeInteractions(): void; + protected _setupListboxNode(): void; protected _teardownListboxNode(): void; diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js index 5ebaed326..e9d9bef3f 100644 --- a/packages/overlays/src/OverlayController.js +++ b/packages/overlays/src/OverlayController.js @@ -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)(); } /** diff --git a/packages/overlays/src/OverlayMixin.js b/packages/overlays/src/OverlayMixin.js index 495e49d88..39f528fa3 100644 --- a/packages/overlays/src/OverlayMixin.js +++ b/packages/overlays/src/OverlayMixin.js @@ -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(); } } }; diff --git a/packages/select-rich/README.md b/packages/select-rich/README.md index adf957c07..9415528f6 100644 --- a/packages/select-rich/README.md +++ b/packages/select-rich/README.md @@ -26,11 +26,9 @@ loadDefaultFeedbackMessages(); ```js preview-story export const main = () => html` - - Red - Hotpink - Teal - + Red + Hotpink + Teal `; ``` @@ -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` - - -

I am red

-

and multi Line

-
- -

I am hotpink

-

and multi Line

-
- -

I am teal

-

and multi Line

-
-
+ +

I am red

+

and multi Line

+
+ +

I am hotpink

+

and multi Line

+
+ +

I am teal

+

and multi Line

+
`; ``` @@ -108,23 +104,21 @@ export const manyOptionsWithScrolling = () => html` } - - -

I am red

-
- -

I am hotpink

-
- -

I am teal

-
- -

I am green

-
- -

I am blue

-
-
+ +

I am red

+
+ +

I am hotpink

+
+ +

I am teal

+
+ +

I am green

+
+ +

I am blue

+
`; ``` @@ -139,11 +133,9 @@ The readonly attribute is delegated to the invoker for disabling opening the ove ```js preview-story export const readOnlyPrefilled = () => html` - - Red - Hotpink - Teal - + Red + Hotpink + Teal `; ``` @@ -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` - - Red - Hotpink - Teal - + Red + Hotpink + Teal `; ``` @@ -169,13 +159,11 @@ export const disabledSelect = () => html` ```js preview-story export const disabledOption = () => html` - - Red - Blue - Hotpink - Green - Teal - + Red + Blue + Hotpink + Green + Teal `; ``` @@ -194,12 +182,10 @@ export const validation = () => { label="Favorite color" .validators="${[new Required()]}" > - - select a color - Red - Hotpink - Teal - + select a color + Red + Hotpink + Teal `; }; @@ -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` - - - ${objs.map( - obj => html` - ${obj.label} - `, - )} - + + ${objs.map( + obj => html` + ${obj.label} + `, + )}

Full value:


@@ -250,18 +234,14 @@ This changes the keyboard interaction.
 ```js preview-story
 export const interactionMode = () => html`
   
-    
-      Red
-      Hotpink
-      Teal
-    
+    Red
+    Hotpink
+    Teal
   
   
-    
-      Red
-      Hotpink
-      Teal
-    
+    Red
+    Hotpink
+    Teal
   
 `;
 ```
@@ -302,11 +282,9 @@ export const checkedIndexAndValue = () => html`
     Console log checked index and value
   
   
-    
-      Red
-      Hotpink
-      Teal
-    
+    Red
+    Hotpink
+    Teal
   
 `;
 ```
@@ -331,11 +309,9 @@ Both methods work with the `Required` validator.
 ```js preview-story
 export const noDefaultSelection = () => html`
   
-    
-      Red
-      Hotpink
-      Teal
-    
+    Red
+    Hotpink
+    Teal
   
 `;
 ```
@@ -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`
   
-    
-      Red
-    
+    Red
   
 `;
 ```
@@ -416,7 +390,7 @@ You can use this `selectedElement` to then render the content to your own invoke
 ```html
 
   
-   ... 
+  ...
 
 ```
 
diff --git a/packages/select-rich/src/LionSelectInvoker.js b/packages/select-rich/src/LionSelectInvoker.js
index a688b99be..0356008bf 100644
--- a/packages/select-rich/src/LionSelectInvoker.js
+++ b/packages/select-rich/src/LionSelectInvoker.js
@@ -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 [
diff --git a/packages/select-rich/src/LionSelectRich.js b/packages/select-rich/src/LionSelectRich.js
index b9fd16651..2a4abae9e 100644
--- a/packages/select-rich/src/LionSelectRich.js
+++ b/packages/select-rich/src/LionSelectRich.js
@@ -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  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
         
         
+
`; @@ -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); } diff --git a/packages/select-rich/test/lion-select-rich-interaction.test.js b/packages/select-rich/test/lion-select-rich-interaction.test.js index 17456f300..a6d25fc1a 100644 --- a/packages/select-rich/test/lion-select-rich-interaction.test.js +++ b/packages/select-rich/test/lion-select-rich-interaction.test.js @@ -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` - - - Item 1 - Item 2 - Item 3 - Item 4 - - + Item 1 `); - expect(el.modelValue).to.equal(30); + expect(el.interactionMode).to.equal('mac'); + const el2 = await fixture(html` + Item 1 + `); + 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` + Item 1 + `); + expect(el3.interactionMode).to.equal('windows/linux'); + const el4 = await fixture(html` + Item 1 + `); + 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` + Item 1 + `); + expect(el.selectionFollowsFocus).to.be.true; + expect(el.navigateWithinInvoker).to.be.true; + + const el2 = await fixture(html` + Item 1 + `); + 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` - - - Item 1 - Item 2 - Item 3 - - - `); - - 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` diff --git a/packages/select-rich/test/lion-select-rich.test.js b/packages/select-rich/test/lion-select-rich.test.js index 3153f0433..157d123ba 100644 --- a/packages/select-rich/test/lion-select-rich.test.js +++ b/packages/select-rich/test/lion-select-rich.test.js @@ -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` - - - - `); + const el = await fixture(html` `); 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` + + + + + + `); + 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` + + Red + Hotpink + Blue + + `); + + 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` - - - - `); + const el = await fixture(html` `); 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` - - Item 1 - Item 2 - + Item 1 + Item 2 `); 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` - - Item 1 - Item 2 - + Item 1 + Item 2 `); 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` - - Item 1 - Item 2 - + Item 1 + Item 2 `); 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` + + Item 1 + + `); + + 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` - - - - `); + const el = await fixture(html` `); expect(el.opened).to.be.false; }); it('shows/hides the listbox via opened attribute', async () => { - const el = await fixture(html` - - - - `); + const el = await fixture(html` `); 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` - - - - `); + const el = await fixture(html` `); const outerEl = await fixture(''); 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` - - - - `); + const el = await fixture(html` `); 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` - - Item 1 - Item 2 - + Item 1 + Item 2 `); 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` - - Item 1 - Item 2 - + Item 1 + Item 2 `); const elDisabled = await fixture(html` - - Item 1 - Item 2 - + Item 1 + Item 2 `); const elSingleoption = await fixture(html` - - Item 1 - + Item 1 `); @@ -203,11 +200,9 @@ describe('lion-select-rich', () => { it('sets inheritsReferenceWidth to min by default', async () => { const el = await fixture(html` - - Red - Hotpink - Teal - + Red + Hotpink + Teal `); @@ -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` - - Red - Hotpink - Teal - + Red + Hotpink + Teal `); @@ -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` - - Item 1 - Item 2 - + Item 1 + Item 2 `); 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` - - - + `); 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` - - - - `); + const el = await fixture(html` `); 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` - - - - `); + const el = await fixture(html` `); 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` - - - - `); + const el = await fixture(html` `); 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` - - - - `); + const el = await fixture(html` `); // 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` - - - - `); + const el = await fixture(html` `); 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` - - Item 1 - + Item 1 `); 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` - - - - `); + const el = await fixture(html` `); 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` - - Item 1 - Item 2 - + Item 1 + Item 2 `); @@ -395,9 +356,7 @@ describe('lion-select-rich', () => { it('opens the listbox with [ArrowUp] key', async () => { const el = await fixture(html` - - - + `); 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` - - - + `); 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` - - Item 1 - Item 2 - + Item 1 + Item 2 `); 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` - - - - `); + const el = await fixture(html` `); 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` - - ${this.colorList.map( - colorObj => html` - ${colorObj.label} - `, - )} - + ${this.colorList.map( + colorObj => html` + ${colorObj.label} + `, + )} `; } @@ -545,13 +493,13 @@ describe('lion-select-rich', () => { const el = await fixture(html` <${mySelectTag} label="Favorite color" name="color"> - + ${Array(2).map( (_, i) => html` value ${i} `, )} - + `); 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> - + Red Hotpink Teal - + `);