Merge pull request #1056 from ing-bank/feat/googleDemoAndManyFixes

chore(combobox): demo and many subclasser features and fixes
This commit is contained in:
Thijs Louisse 2020-11-03 16:30:04 +01:00 committed by GitHub
commit c242204709
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1107 additions and 176 deletions

View file

@ -0,0 +1,29 @@
---
"@lion/combobox": patch
"@lion/listbox": patch
---
Combobox: demos, Subclasser features and fixes
### Features
- Subclassers can configure `_syncToTextboxCondition()`. By default only for `autocomplete="inline|both"`
- Subclassers can configure `_showOverlayCondition(options)`. For instance, already show once textbox gets focus or add your own custom
- Subclassers can configure `_syncToTextboxMultiple(modelValue, oldModelValue)`. See https://github.com/ing-bank/lion/issues/1038
- Subclassers can configure `_autoSelectCondition`, for instance to have autcomplete="list" with auto select instead of manual selection. Both are possible according to w3c specs
### Fixes
- listbox multiselect can deselect again on 'Enter' and 'Space'. Closes https://github.com/ing-bank/lion/issues/1059
- combobox multiselect display only shows last selected option in textbox (instead of all). See https://github.com/ing-bank/lion/issues/1038
- default sync to textbox behavior for `autocomplete="none|list"` is no sync with textbox
### Demos
- created a google combobox demo (with anchors as options)
- advanced styling example
- uses autocomplete 'list' as a fundament and enhances `_showOverlayCondition` and `_syncToTextboxCondition`
- enhanced whatsapp combobox demo
- how to match/highlight text on multiple rows of the option (not just choiceValue)
### Potentially breaking for subclassers:
- `_computeUserIntendsAutoFill` -> `__computeUserIntendsAutoFill` (not overridable)
- `_syncCheckedWithTextboxOnInteraction ` is removed. Use `_syncToTextboxCondition` and/or `_syncToTextboxMultiple`

View file

@ -67,7 +67,7 @@ to the configurable values `none`, `list`, `inline` and `both`.
| | list | filter | focus | check | complete | | | list | filter | focus | check | complete |
| -----: | :--: | :----: | :---: | :---: | :------: | | -----: | :--: | :----: | :---: | :---: | :------: |
| none | ✓ | | | | | | none | ✓ | | | | |
| list | ✓ | ✓ | ✓ | ✓ | | | list | ✓ | ✓ | | | |
| inline | ✓ | | ✓ | ✓ | ✓ | | inline | ✓ | | ✓ | ✓ | ✓ |
| both | ✓ | ✓ | ✓ | ✓ | ✓ | | both | ✓ | ✓ | ✓ | ✓ | ✓ |

View file

@ -5,13 +5,22 @@ import { html } from 'lit-html';
import './md-combobox/md-combobox.js'; import './md-combobox/md-combobox.js';
import './gh-combobox/gh-combobox.js'; import './gh-combobox/gh-combobox.js';
import './wa-combobox/wa-combobox.js'; import './wa-combobox/wa-combobox.js';
import './lm-option/lm-option.js'; import './google-combobox/google-combobox.js';
export default { export default {
title: 'Forms/Combobox/Extensions', title: 'Forms/Combobox/Extensions',
}; };
``` ```
Below several extensions can be found. They illustrate that complex UI components can be created
easily from an extended Lion component, just by:
- **configuring**: setting properties or providing conditions via methods
- **enhancing**: adding extra html/styles/logic without changing behavior of the extended element
- **overriding**: replace html/styles/logic of the extended element with your own
## Material Design
```js preview-story ```js preview-story
export const MaterialDesign = () => html` export const MaterialDesign = () => html`
<md-combobox name="combo" label="Default"> <md-combobox name="combo" label="Default">
@ -24,6 +33,8 @@ export const MaterialDesign = () => html`
`; `;
``` ```
## Github
```js preview-story ```js preview-story
export const Github = () => html` export const Github = () => html`
<gh-combobox name="combo" label="Switch branches/tags"> <gh-combobox name="combo" label="Switch branches/tags">
@ -36,6 +47,8 @@ export const Github = () => html`
`; `;
``` ```
## Whatsapp
```js preview-story ```js preview-story
export const Whatsapp = () => html` export const Whatsapp = () => html`
<wa-combobox name="combo" label="Filter chats"> <wa-combobox name="combo" label="Filter chats">
@ -81,44 +94,66 @@ export const Whatsapp = () => html`
`; `;
``` ```
**Whatsapp example shows:**
- advanced styling
- how to match/highlight text on multiple rows of the option (not just choiceValue)
- how to animate options
## Google Search
```js preview-story ```js preview-story
export const LinkMixinBox = () => html` export const GoogleSearch = () => {
<lion-combobox name="combo" label="Default"> const appleLogoUrl = new URL('./google-combobox/assets/appleLogo.png', import.meta.url).href;
<lm-option return html`
href="https://www.google.com/search?query=apple" <google-combobox name="combo" label="Google Search">
target="_blank" <google-option
rel="noopener noreferrer" href="https://www.google.com/search?query=apple"
.choiceValue=${'Apple'} target="_blank"
>Apple</lm-option rel="noopener noreferrer"
> .choiceValue=${'Apple'}
<lm-option .imageUrl=${appleLogoUrl}
href="https://www.google.com/search?query=Artichoke" >Apple</google-option
target="_blank" >
rel="noopener noreferrer" <google-option
.choiceValue=${'Artichoke'} href="https://www.google.com/search?query=Artichoke"
>Artichoke</lm-option target="_blank"
> rel="noopener noreferrer"
<lm-option .choiceValue=${'Artichoke'}
href="https://www.google.com/search?query=Asparagus" >Artichoke</google-option
target="_blank" >
rel="noopener noreferrer" <google-option
.choiceValue=${'Asparagus'} href="https://www.google.com/search?query=Asparagus"
>Asparagus</lm-option target="_blank"
> rel="noopener noreferrer"
<lm-option .choiceValue=${'Asparagus'}
href="https://www.google.com/search?query=Banana" >Asparagus</google-option
target="_blank" >
rel="noopener noreferrer" <google-option
.choiceValue=${'Banana'} href="https://www.google.com/search?query=Banana"
>Banana</lm-option target="_blank"
> rel="noopener noreferrer"
<lm-option .choiceValue=${'Banana'}
href="https://www.google.com/search?query=Beets" >Banana</google-option
target="_blank" >
rel="noopener noreferrer" <google-option
.choiceValue=${'Beets'} href="https://www.google.com/search?query=Beets"
>Beets</lm-option target="_blank"
> rel="noopener noreferrer"
</lion-combobox> .choiceValue=${'Beets'}
`; >Beets</google-option
>
</google-combobox>
<div style="height:200px;"></div>
`;
};
``` ```
**Google Search example shows:**
- advanced styling
- how to use options that are links
- create exact user experience of Google Search, by:
- using autocomplete 'list' as a fundament (we don't want inline completion in textbox)
- enhancing `_showOverlayCondition`: open on focus
- enhancing `_syncToTextboxCondition`: always sync to textbox when navigating by keyboard (this needs to be enabled, since it's not provided in the "autocomplete=list" preset)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,9 @@
import { html } from '@lion/core';
export default html`
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
></path>
</svg>
`;

View file

@ -0,0 +1,9 @@
import { html } from '@lion/core';
export default html`
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
></path>
</svg>
`;

View file

@ -0,0 +1,19 @@
import { html } from '@lion/core';
export default html`
<svg class="HPVvwb" focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="m12 15c1.66 0 3-1.31 3-2.97v-7.02c0-1.66-1.34-3.01-3-3.01s-3 1.34-3 3.01v7.02c0 1.66 1.34 2.97 3 2.97z"
fill="#4285f4"
></path>
<path d="m11 18.08h2v3.92h-2z" fill="#34a853"></path>
<path
d="m7.05 16.87c-1.27-1.33-2.05-2.83-2.05-4.87h2c0 1.45 0.56 2.42 1.47 3.38v0.32l-1.15 1.18z"
fill="#f4b400"
></path>
<path
d="m12 16.93a4.97 5.25 0 0 1 -3.54 -1.55l-1.41 1.49c1.26 1.34 3.02 2.13 4.95 2.13 3.87 0 6.99-2.92 6.99-7h-1.99c0 2.92-2.24 4.93-5 4.93z"
fill="#ea4335"
></path>
</svg>
`;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -0,0 +1,460 @@
import { css, html } from '@lion/core';
import { LionOption } from '@lion/listbox';
import { renderLitAsNode } from '@lion/helpers';
import { LionCombobox } from '../../src/LionCombobox.js';
import { LinkMixin } from '../LinkMixin.js';
import googleSearchIcon from './assets/google-search-icon.js';
import googleVoiceSearchIcon from './assets/google-voice-search-icon.js';
import googleClearIcon from './assets/google-clear-icon.js';
const googleSearchLogoUrl = new URL('./assets/googlelogo_color_272x92dp.png', import.meta.url).href;
export class GoogleOption extends LinkMixin(LionOption) {
static get properties() {
return {
imageUrl: {
type: String,
},
};
}
static get styles() {
return [
super.styles,
css`
:host {
position: relative;
padding: 8px 16px;
display: flex;
align-items: center;
background: none;
}
:host:hover,
:host([active]) {
background: #eee !important;
}
:host([checked]) {
background: none;
}
/* :host([active]) {
color: #1867c0 !important;
caret-color: #1867c0 !important;
} */
:host {
font-weight: bold;
}
:host ::slotted(.google-option__highlight) {
font-weight: normal;
}
.google-option__icon {
height: 20px;
width: 20px;
margin-right: 12px;
fill: var(--icon-color);
}
`,
];
}
/**
* @configure
* @param {string} currentValue
*/
onFilterMatch(currentValue) {
const { innerHTML } = this;
// eslint-disable-next-line no-param-reassign
this.__originalInnerHTML = innerHTML;
const newInnerHTML = innerHTML.replace(
new RegExp(`(${currentValue})`, 'i'),
`<span class="google-option__highlight">$1</span>`,
);
// For Safari, we need to add a label to the element
this.setAttribute('aria-label', this.textContent);
this.innerHTML = newInnerHTML;
// Alternatively, an extension can add an animation here
this.style.display = '';
}
/**
* @configure LionCombobox
*/
onFilterUnmatch() {
this.removeAttribute('aria-label');
if (this.__originalInnerHTML) {
this.innerHTML = this.__originalInnerHTML;
}
this.style.display = 'none';
}
render() {
return html`
${!this.imageUrl
? html` <div class="google-option__icon">${googleSearchIcon}</div>`
: html` <img class="google-option__icon" src="${this.imageUrl}" />`}
${super.render()}
`;
}
}
customElements.define('google-option', GoogleOption);
export class GoogleCombobox extends LionCombobox {
static get styles() {
return [
super.styles,
css`
/** @configure FormControlMixin */
/* =======================
block | .form-field
======================= */
:host {
font-family: arial, sans-serif;
}
.form-field__label {
margin-top: 36px;
margin-bottom: 24px;
display: flex;
justify-content: center;
}
/* ==============================
element | .input-group
============================== */
.input-group {
margin-bottom: 16px;
max-width: 582px;
margin: auto;
}
.input-group__container {
position: relative;
background: #fff;
display: flex;
border: 1px solid #dfe1e5;
box-shadow: none;
border-radius: 24px;
height: 44px;
}
.input-group__container:hover,
:host([opened]) .input-group__container {
border-color: rgba(223, 225, 229, 0);
box-shadow: 0 1px 6px rgba(32, 33, 36, 0.28);
}
:host([opened]) .input-group__container {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
:host([opened]) .input-group__container::before {
content: '';
position: absolute;
background: white;
left: 0;
right: 0;
height: 10px;
bottom: -10px;
}
:host([opened]) .input-group__container::after {
content: '';
position: absolute;
background: #eee;
left: 16px;
right: 16px;
height: 1px;
bottom: 0;
z-index: 3;
}
.input-group__prefix,
.input-group__suffix {
display: block;
fill: var(--icon-color);
display: flex;
place-items: center;
}
.input-group__input {
flex: 1;
}
.input-group__input ::slotted([slot='input']) {
border: transparent;
width: 100%;
}
/** @configure LionCombobox */
/* =======================
block | .form-field
======================= */
#overlay-content-node-wrapper {
box-shadow: 0 4px 6px rgba(32, 33, 36, 0.28);
border-radius: 0 0 24px 24px;
margin-top: -2px;
padding-top: 6px;
background: white;
}
* > ::slotted([slot='listbox']) {
margin-bottom: 8px;
background: none;
}
:host {
--icon-color: #9aa0a6;
}
/** @enhance LionCombobox */
/* ===================================
block | .google-search-clear-btn
=================================== */
.google-search-clear-btn {
position: relative;
height: 100%;
align-items: center;
display: none;
}
.google-search-clear-btn::after {
border-left: 1px solid #dfe1e5;
height: 65%;
right: 0;
content: '';
margin-right: 10px;
margin-left: 8px;
}
:host([filled]) .google-search-clear-btn {
display: flex;
}
* > ::slotted([slot='suffix']),
* > ::slotted([slot='clear-btn']) {
font: inherit;
margin: 0;
border: 0;
outline: 0;
padding: 0;
color: inherit;
background-color: transparent;
text-align: left;
white-space: normal;
overflow: visible;
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-tap-highlight-color: transparent;
width: 25px;
height: 25px;
cursor: pointer;
}
* > ::slotted([slot='suffix']) {
margin-right: 20px;
}
* > ::slotted([slot='prefix']) {
height: 20px;
width: 20px;
margin-left: 12px;
margin-right: 16px;
}
/* =============================
block | .google-search-btns
============================ */
.google-search-btns {
display: flex;
justify-content: center;
align-items: center;
}
.google-search-btns__input-button {
background-image: -webkit-linear-gradient(top, #f8f9fa, #f8f9fa);
background-color: #f8f9fa;
border: 1px solid #f8f9fa;
border-radius: 4px;
color: #3c4043;
font-family: arial, sans-serif;
font-size: 14px;
margin: 11px 4px;
padding: 0 16px;
line-height: 27px;
height: 36px;
min-width: 54px;
text-align: center;
cursor: pointer;
user-select: none;
}
.google-search-btns__input-button:hover {
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
background-image: -webkit-linear-gradient(top, #f8f8f8, #f1f1f1);
background-color: #f8f8f8;
border: 1px solid #c6c6c6;
color: #222;
}
.google-search-btns__input-button:focus {
border: 1px solid #4d90fe;
outline: none;
}
/* ===============================
block | .google-search-report
============================== */
.google-search-report {
display: flex;
align-content: right;
color: #70757a;
font-style: italic;
font-size: 8pt;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
margin-bottom: 8px;
justify-content: flex-end;
margin-right: 20px;
}
.google-search-report a {
color: inherit;
}
`,
];
}
/**
* @enhance LionCombobox - add google search buttons
*/
_overlayListboxTemplate() {
return html`
<div id="overlay-content-node-wrapper" role="dialog">
<slot name="listbox"></slot>
${this._googleSearchBtnsTemplate()}
<div class="google-search-report"><a href="#">Report inappropriate predictions</a></div>
</div>
<slot id="options-outlet"></slot>
`;
}
/**
* @enhance FormControlMixin add clear-btn
*/
_inputGroupSuffixTemplate() {
return html`
<div class="input-group__suffix">
<div class="google-search-clear-btn">
<slot name="clear-btn"></slot>
</div>
<slot name="suffix"></slot>
</div>
`;
}
_googleSearchBtnsTemplate() {
return html` <div class="google-search-btns">
<input
type="submit"
class="google-search-btns__input-button"
value="Google Search"
aria-label="Google Search"
/>
<input
type="submit"
class="google-search-btns__input-button"
value="I'm Feeling Lucky"
aria-label="I'm Feeling Lucky"
/>
</div>`;
}
/**
* @enhance FormControlMixin - add google search buttons
*/
_groupTwoTemplate() {
return html`${super._groupTwoTemplate()} ${!this.opened ? this._googleSearchBtnsTemplate() : ''} `;
}
get slots() {
return {
...super.slots,
label: () => renderLitAsNode(html` <img alt="Google Search" src="${googleSearchLogoUrl}" />`),
prefix: () => renderLitAsNode(html` <span>${googleSearchIcon}</span> `),
suffix: () =>
renderLitAsNode(
html` <button aria-label="Search by voice">${googleVoiceSearchIcon}</button> `,
),
'clear-btn': () =>
renderLitAsNode(
html`
<button @click="${this.__clearText}" aria-label="Clear text">${googleClearIcon}</button>
`,
),
};
}
/**
* @configure OverlayMixin
*/
get _overlayReferenceNode() {
return /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('.input-group');
}
constructor() {
super();
/** @configure LionCombobox */
this.autocomplete = 'list';
/** @configure LionCombobox */
this.showAllOnEmpty = true;
this.__resetFocus = this.__resetFocus.bind(this);
this.__clearText = this.__clearText.bind(this);
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._overlayContentNode.addEventListener('mouseenter', this.__resetFocus);
}
/**
* @override LionCombobox - always sync textbox when selected value changes
*/
// eslint-disable-next-line no-unused-vars
_syncToTextboxCondition() {
return true;
}
_showOverlayCondition(options) {
return this.focused || super.showOverlayCondition(options);
}
__resetFocus() {
this.activeIndex = -1;
this.checkedIndex = -1;
}
__clearText() {
this._inputNode.value = '';
}
}
customElements.define('google-combobox', GoogleCombobox);

View file

@ -1,6 +0,0 @@
import { LionOption } from '@lion/listbox';
import { LinkMixin } from '../LinkMixin.js';
export class LmOption extends LinkMixin(LionOption) {}
customElements.define('lm-option', LmOption);

View file

@ -167,9 +167,9 @@ class WaOption extends LionOption {
:host([is-user-text-read]) .wa-option__content-row2-text-inner-icon { :host([is-user-text-read]) .wa-option__content-row2-text-inner-icon {
color: lightblue; color: lightblue;
} }
/* .wa-selected {
.wa-option__content-row2-menu { color: #009688;
} */ }
`, `,
]; ];
} }
@ -178,13 +178,7 @@ class WaOption extends LionOption {
return html`<div class="wa-option"> return html`<div class="wa-option">
<div class="wa-option__image"> <div class="wa-option__image">
<div class="wa-option__image-inner"> <div class="wa-option__image-inner">
<img <img src="${this.image}" alt="" draggable="false" style="visibility: visible;" />
src="${this.image}"
alt=""
draggable="false"
class="_2goTk _1Jdop _3Whw5"
style="visibility: visible;"
/>
${this.image ${this.image
? '' ? ''
: html`<div class="wa-option__image-inner-inner"> : html`<div class="wa-option__image-inner-inner">
@ -213,11 +207,9 @@ class WaOption extends LionOption {
<div class="wa-option__content"> <div class="wa-option__content">
<div class="wa-option__content-row1"> <div class="wa-option__content-row1">
<div class="wa-option__content-row1-title"> <div class="wa-option__content-row1-title">
<span class="_357i8"> <span>
<span dir="auto" title="${this.title}" class="_3ko75 _5h6Y_ _3Whw5"> <span dir="auto" title="${this.title}"> ${this.title} </span>
${this.title} <div></div>
</span>
<div class="_3XFan"></div>
</span> </span>
</div> </div>
<div class="wa-option__content-row1-time">${this.time}</div> <div class="wa-option__content-row1-time">${this.time}</div>
@ -226,7 +218,7 @@ class WaOption extends LionOption {
<div class="wa-option__content-row2-text"> <div class="wa-option__content-row2-text">
<span class="wa-option__content-row2-text-inner" title="${this.text}"> <span class="wa-option__content-row2-text-inner" title="${this.text}">
<div class="wa-option__content-row2-text-inner-icon"> <div class="wa-option__content-row2-text-inner-icon">
<span data-testid="status-dblcheck" data-icon="status-dblcheck" class=""> <span>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18" viewBox="0 0 18 18"
@ -240,7 +232,7 @@ class WaOption extends LionOption {
</svg> </svg>
</span> </span>
</div> </div>
<span dir="ltr" class="_3ko75 _5h6Y_ _3Whw5">${this.text}</span></span <span dir="ltr">${this.text}</span></span
> >
</div> </div>
<div class="wa-option__content-row2-menu"><span></span><span></span><span></span></div> <div class="wa-option__content-row2-menu"><span></span><span></span><span></span></div>
@ -255,11 +247,23 @@ class WaOption extends LionOption {
*/ */
onFilterMatch(matchingString) { onFilterMatch(matchingString) {
this.__originalTitle = this.title; this.__originalTitle = this.title;
const newInnerHTML = this.title.replace(new RegExp(`(${matchingString})`, 'i'), `<b>$1</b>`); this.__originalText = this.text;
const newTitle = this.__originalTitle.replace(
new RegExp(`(${matchingString})`, 'i'),
`<b class="wa-selected">$1</b>`,
);
const newText = this.__originalText.replace(
new RegExp(`(${matchingString})`, 'i'),
`<b class="wa-selected">$1</b>`,
);
const helperNode = document.createElement('div'); const helperNode = document.createElement('div');
// For Safari, we need to add a label to the element // For Safari, we need to add a label to the element
helperNode.innerHTML = `<span aria-label="${this.title}">${newInnerHTML}</span>`; helperNode.innerHTML = `<span aria-label="${this.title}">${newTitle}</span>`;
[this.title] = helperNode.children; [this.title] = helperNode.children;
helperNode.innerHTML = `<span aria-label="${this.text}">${newText}</span>`;
[this.text] = helperNode.children;
// Show animation
this.style.cssText = ` this.style.cssText = `
max-height: 500px; max-height: 500px;
opacity: 1; opacity: 1;
@ -276,6 +280,9 @@ class WaOption extends LionOption {
if (this.__originalTitle) { if (this.__originalTitle) {
this.title = this.__originalTitle; this.title = this.__originalTitle;
} }
if (this.__originalText) {
this.text = this.__originalText;
}
this.style.cssText = ` this.style.cssText = `
max-height: 0; max-height: 0;
opacity: 0; opacity: 0;
@ -363,6 +370,38 @@ class WaCombobox extends LionCombobox {
this.showAllOnEmpty = true; this.showAllOnEmpty = true;
/** @configure LionCombobox */ /** @configure LionCombobox */
this.rotateKeyboardNavigation = false; this.rotateKeyboardNavigation = false;
/** @configure LionCombobox */
this.autocomplete = 'list';
}
/**
* @override LionCombobox - also match option.text
* @param {LionOption} option
* @param {string} textboxValue current ._inputNode value
*/
matchCondition(option, textboxValue) {
let idx = -1;
if (typeof option.choiceValue === 'string' && typeof textboxValue === 'string') {
idx = option.choiceValue.toLowerCase().indexOf(textboxValue.toLowerCase());
// enhance LionCombobox: also match option.text
const text = option.__originalText || option.text;
if (idx === -1 && typeof text === 'string') {
idx = text.toLowerCase().indexOf(textboxValue.toLowerCase());
}
}
if (this.matchMode === 'all') {
return idx > -1; // matches part of word
}
return idx === 0; // matches beginning of value
}
/**
* @override LionCombobox - always sync textbox when selected value changes
*/
// eslint-disable-next-line no-unused-vars
_syncToTextboxCondition() {
return true;
} }
} }
customElements.define('wa-combobox', WaCombobox); customElements.define('wa-combobox', WaCombobox);

View file

@ -38,7 +38,8 @@
"docs/md-combobox/md-combobox.js", "docs/md-combobox/md-combobox.js",
"docs/md-combobox/md-input.js", "docs/md-combobox/md-input.js",
"docs/md-combobox/style/md-ripple.js", "docs/md-combobox/style/md-ripple.js",
"docs/md-combobox/style/load-roboto.js" "docs/md-combobox/style/load-roboto.js",
"docs/google-combobox/google-combobox.js"
], ],
"dependencies": { "dependencies": {
"@lion/core": "0.13.3", "@lion/core": "0.13.3",

View file

@ -70,15 +70,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
]; ];
} }
/**
* @enhance FormControlMixin - add form-control to [slot=input] instead of _inputNode
*/
_enhanceLightDomClasses() {
if (this.querySelector('[slot=input]')) {
this.querySelector('[slot=input]').classList.add('form-control');
}
}
/** /**
* @enhance FormControlMixin - add slot[name=selection-display] * @enhance FormControlMixin - add slot[name=selection-display]
*/ */
@ -104,7 +95,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
} }
/** /**
* @enhance FormControlMixin * @enhance FormControlMixin - add overlay
*/ */
_groupTwoTemplate() { _groupTwoTemplate() {
return html` ${super._groupTwoTemplate()} ${this._overlayListboxTemplate()}`; return html` ${super._groupTwoTemplate()} ${this._overlayListboxTemplate()}`;
@ -274,7 +265,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
this.__prevCboxValue = ''; this.__prevCboxValue = '';
/** @type {EventListener} */ /** @type {EventListener} */
this.__showOverlay = this.__showOverlay.bind(this); this.__requestShowOverlay = this.__requestShowOverlay.bind(this);
/** @type {EventListener} */ /** @type {EventListener} */
this._textboxOnInput = this._textboxOnInput.bind(this); this._textboxOnInput = this._textboxOnInput.bind(this);
/** @type {EventListener} */ /** @type {EventListener} */
@ -297,9 +288,13 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
if (name === 'disabled' || name === 'readOnly') { if (name === 'disabled' || name === 'readOnly') {
this.__setComboboxDisabledAndReadOnly(); this.__setComboboxDisabledAndReadOnly();
} }
if (name === 'modelValue' && this.modelValue !== oldValue) { if (name === 'modelValue' && this.modelValue && this.modelValue !== oldValue) {
if (this.modelValue) { if (this._syncToTextboxCondition(this.modelValue, this.__oldModelValue)) {
this._setTextboxValue(this.modelValue); if (!this.multipleChoice) {
this._setTextboxValue(this.modelValue);
} else {
this._syncToTextboxMultiple(this.modelValue, this.__oldModelValue);
}
} }
} }
} }
@ -309,6 +304,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
*/ */
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('focused')) {
if (this.focused) {
this.__requestShowOverlay();
}
}
if (changedProperties.has('opened')) { if (changedProperties.has('opened')) {
if (this.opened) { if (this.opened) {
@ -318,7 +318,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
} }
if (!this.opened && changedProperties.get('opened') !== undefined) { if (!this.opened && changedProperties.get('opened') !== undefined) {
this._syncCheckedWithTextboxOnInteraction(); this.__onOverlayClose();
this.activeIndex = -1; this.activeIndex = -1;
} }
} }
@ -362,6 +362,38 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
return idx === 0; // matches beginning of value return idx === 0; // matches beginning of value
} }
/**
* @overridable
* Allows Sub Classer to control when the overlay should become visible
* Note that this condition is separate from whether the option listbox is
* shown (use 'showAllOnEmpty, matchMode and autocomplete configurations for this')
*
* Separating these conditions allows the user to show different content in the dialog/overlay
* that wraps the listbox with options
*
* @example
* _showOverlayCondition(options) {
* return this.focused || super.showOverlayCondition(options);
* }
*
* @example
* _showOverlayCondition({ lastKey }) {
* return lastKey === 'ArrowDown';
* }
*
* @example
* _showOverlayCondition(options) {
* return options.currentValue.length > 4 && super.showOverlayCondition(options);
* }
*
* @param {{ currentValue: string, lastKey:string }} options
*/
// eslint-disable-next-line class-methods-use-this
_showOverlayCondition({ lastKey }) {
const doNotOpenOn = ['Tab', 'Esc', 'Enter'];
return lastKey && !doNotOpenOn.includes(lastKey);
}
/** /**
* @param {Event} ev * @param {Event} ev
*/ */
@ -378,7 +410,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
if (ev.key === 'Tab') { if (ev.key === 'Tab') {
this.opened = false; this.opened = false;
} }
this.__hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart;
} }
/** /**
@ -397,33 +428,26 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @param {string} v * @param {string} v
*/ */
_setTextboxValue(v) { _setTextboxValue(v) {
// Make sure that we don't loose inputNode.selectionStart and inputNode.selectionEnd
if (this._inputNode.value !== v) { if (this._inputNode.value !== v) {
this._inputNode.value = v; this._inputNode.value = v;
} }
} }
/** __onOverlayClose() {
* For multiple choice, a subclasser could do something like: if (!this.multipleChoice) {
* @example if (this.checkedIndex !== -1) {
* _syncCheckedWithTextboxOnInteraction() { this._inputNode.value = this.formElements[
* super._syncCheckedWithTextboxOnInteraction(); /** @type {number} */ (this.checkedIndex)
* if (this.multipleChoice) { ].choiceValue;
* this._inputNode.value = this.checkedElements.map(o => o.value).join(', '); }
* } } else {
* } this._syncToTextboxMultiple(this.modelValue, this.__oldModelValue);
* @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;
} }
} }
/** /**
* @enhance FormControlMixin
* We need to extend the repropagation prevention conditions here. * We need to extend the repropagation prevention conditions here.
* Usually form groups with single choice will not repropagate model-value-changed of an option upwards * Usually form groups with single choice will not repropagate model-value-changed of an option upwards
* if this option itself is not the checked one. We want to prevent duplicates. However, for combobox * if this option itself is not the checked one. We want to prevent duplicates. However, for combobox
@ -475,24 +499,30 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
/** /**
* Computes whether a user intends to autofill (inline autocomplete textbox) * Computes whether a user intends to autofill (inline autocomplete textbox)
* @overridable
* @param {{ prevValue:string, curValue:string }} config * @param {{ prevValue:string, curValue:string }} config
*/ */
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
_computeUserIntendsAutoFill({ prevValue, curValue }) { __computeUserIntendsAutoFill({ prevValue, curValue }) {
const userIsAddingChars = prevValue.length < curValue.length; const userIsAddingChars = prevValue.length < curValue.length;
const userStartsNewWord = const userStartsNewWord =
prevValue.length && prevValue.length &&
curValue.length && curValue.length &&
prevValue[0].toLowerCase() !== curValue[0].toLowerCase(); prevValue[0].toLowerCase() !== curValue[0].toLowerCase();
return userIsAddingChars || userStartsNewWord; return userIsAddingChars || userStartsNewWord;
} }
/* eslint-enable no-param-reassign, class-methods-use-this */ /* eslint-enable no-param-reassign, class-methods-use-this */
/** /**
* Matches visibility of listbox options against current ._inputNode contents * Handles autocompletion. This entails:
* - list: shows a list on keydown character press
* - filter: filters list of potential matches according to matchmode or provided matchCondition
* - 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)
*
*/ */
_handleAutocompletion() { _handleAutocompletion() {
// TODO: this is captured by 'noFilter' // TODO: this is captured by 'noFilter'
@ -502,8 +532,13 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
return; return;
} }
const hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart;
const curValue = this._inputNode.value; const curValue = this._inputNode.value;
const prevValue = this.__hasSelection ? this.__prevCboxValueNonSelected : this.__prevCboxValue; const prevValue =
hasSelection || this.__hadSelectionLastAutofill
? this.__prevCboxValueNonSelected
: this.__prevCboxValue;
const isEmpty = !curValue; const isEmpty = !curValue;
/** /**
@ -511,38 +546,55 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @type {LionOption[]} * @type {LionOption[]}
*/ */
const visibleOptions = []; const visibleOptions = [];
/** Whether autofill (activeIndex/checkedIndex and ) has taken place in this 'cycle' */
let hasAutoFilled = false; let hasAutoFilled = false;
const userIntendsAutoFill = this._computeUserIntendsAutoFill({ prevValue, curValue }); const userIntendsInlineAutoFill = this.__computeUserIntendsAutoFill({ prevValue, curValue });
const isCandidate = this.autocomplete === 'both' || this.autocomplete === 'inline'; const isInlineAutoFillCandidate =
this.autocomplete === 'both' || this.autocomplete === 'inline';
const autoselect = this._autoSelectCondition();
// @ts-ignore this.autocomplete === 'none' needs to be there if statement above is removed // @ts-ignore this.autocomplete === 'none' needs to be there if statement above is removed
const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none'; const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none';
/** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */ /** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */
this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, i) => { this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, i) => {
// [1]. Decide whether otion should be shown // [1]. Decide whether option should be shown
const matches = this.matchCondition(option, curValue);
let show = false; let show = false;
if (isEmpty) { if (isEmpty) {
show = this.showAllOnEmpty; show = this.showAllOnEmpty;
} else { } else {
show = noFilter ? true : this.matchCondition(option, curValue); show = noFilter || matches;
} }
// [2]. Synchronize ._inputNode value and active descendant with closest match // [2]. Synchronize ._inputNode value and active descendant with closest match
if (isCandidate && !hasAutoFilled && show && userIntendsAutoFill && !option.disabled) { if (autoselect && !hasAutoFilled && matches && !option.disabled) {
const stringValues = typeof option.choiceValue === 'string' && typeof curValue === 'string'; const doAutoSelect = () => {
const beginsWith =
stringValues && option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase()) === 0;
if (beginsWith) {
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; this.activeIndex = i;
if (this.selectionFollowsFocus && !this.multipleChoice) { if (this.selectionFollowsFocus && !this.multipleChoice) {
this.setCheckedIndex(this.activeIndex); this.setCheckedIndex(this.activeIndex);
} }
hasAutoFilled = true; hasAutoFilled = true;
};
if (userIntendsInlineAutoFill) {
// We should never directly select when removing chars or starting a new word
// This leads to bad UX and unwanted syncing of modelValue (based on checkedIndex)
// and _inputNode.value
if (isInlineAutoFillCandidate) {
const stringValues =
typeof option.choiceValue === 'string' && typeof curValue === 'string';
const beginsWith =
stringValues &&
option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase()) === 0;
// We only can do proper inline autofilling when the beginning of the word matches
if (beginsWith) {
this.__textboxInlineComplete(option);
doAutoSelect();
}
} else {
doAutoSelect();
}
} }
} }
@ -578,7 +630,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
}); });
// [7]. If no autofill took place, we are left with the previously matched option; correct this // [7]. If no autofill took place, we are left with the previously matched option; correct this
if (!hasAutoFilled && isCandidate && !this.multipleChoice) { if (!hasAutoFilled && autoselect && !this.multipleChoice) {
// This means there is no match for checkedIndex // This means there is no match for checkedIndex
this.checkedIndex = -1; this.checkedIndex = -1;
} }
@ -587,7 +639,8 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
this.__prevCboxValueNonSelected = curValue; this.__prevCboxValueNonSelected = curValue;
// See test 'computation of "user intends autofill" works correctly afer autofill' // See test 'computation of "user intends autofill" works correctly afer autofill'
this.__prevCboxValue = this._inputNode.value; this.__prevCboxValue = this._inputNode.value;
this.__hasSelection = hasAutoFilled; this.__hadSelectionLastAutofill =
this._inputNode.value.length !== this._inputNode.selectionStart;
// [9]. Reposition overlay // [9]. Reposition overlay
if (this._overlayCtrl && this._overlayCtrl._popper) { if (this._overlayCtrl && this._overlayCtrl._popper) {
@ -595,6 +648,23 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
} }
} }
__textboxInlineComplete(option = this.formElements[this.activeIndex]) {
const prevLen = this._inputNode.value.length;
this._inputNode.value = option.choiceValue;
this._inputNode.selectionStart = prevLen;
this._inputNode.selectionEnd = this._inputNode.value.length;
}
/**
* When this condition is false, an end user will have to manually select a suggested
* option from the list (by default when autocomplete is 'none' or 'list').
* For autocomplete 'both' or 'inline', it will automatically select on a match.
* @overridable
*/
_autoSelectCondition() {
return this.autocomplete === 'both' || this.autocomplete === 'inline';
}
/** /**
* @enhance ListboxMixin * @enhance ListboxMixin
*/ */
@ -629,7 +699,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
*/ */
_setupOpenCloseListeners() { _setupOpenCloseListeners() {
super._setupOpenCloseListeners(); super._setupOpenCloseListeners();
this._overlayInvokerNode.addEventListener('keydown', this.__showOverlay); this._inputNode.addEventListener('keydown', this.__requestShowOverlay);
} }
/** /**
@ -637,7 +707,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
*/ */
_teardownOpenCloseListeners() { _teardownOpenCloseListeners() {
super._teardownOpenCloseListeners(); super._teardownOpenCloseListeners();
this._overlayInvokerNode.removeEventListener('keydown', this.__showOverlay); this._inputNode.removeEventListener('keydown', this.__requestShowOverlay);
} }
/** /**
@ -650,7 +720,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
switch (key) { switch (key) {
case 'Escape': case 'Escape':
this.opened = false; this.opened = false;
this.__shouldAutocompleteNextUpdate = true;
this._setTextboxValue(''); this._setTextboxValue('');
break; break;
case 'Enter': case 'Enter':
@ -665,6 +734,35 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
} }
} }
/**
* @param {string|string[]} modelValue
* @param {string|string[]} oldModelValue
*/
// eslint-disable-next-line no-unused-vars
_syncToTextboxCondition(modelValue, oldModelValue) {
return this.autocomplete === 'inline' || this.autocomplete === 'both';
}
/**
* @overridable
* Allows to control what happens when checkedIndexes change
* @param {string[]} modelValue
* @param {string[]} oldModelValue
*/
_syncToTextboxMultiple(modelValue, oldModelValue = []) {
const diff = modelValue.filter(x => !oldModelValue.includes(x));
this._setTextboxValue(diff); // or last selected value?
}
/**
* @override FormControlMixin - add form-control to [slot=input] instead of _inputNode
*/
_enhanceLightDomClasses() {
if (this.querySelector('[slot=input]')) {
this.querySelector('[slot=input]').classList.add('form-control');
}
}
__initFilterListbox() { __initFilterListbox() {
this._handleAutocompletion(); this._handleAutocompletion();
} }
@ -705,12 +803,16 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
} }
/** /**
* @param {KeyboardEvent} ev * @param {KeyboardEvent} [ev]
*/ */
__showOverlay(ev) { __requestShowOverlay(ev) {
if (ev.key === 'Tab' || ev.key === 'Esc' || ev.key === 'Enter') { if (
return; this._showOverlayCondition({
lastKey: ev && ev.key,
currentValue: this._inputNode.value,
})
) {
this.opened = true;
} }
this.opened = true;
} }
} }

View file

@ -5,6 +5,7 @@ import '../lion-combobox.js';
import { LionOptions } from '@lion/listbox/src/LionOptions.js'; import { LionOptions } from '@lion/listbox/src/LionOptions.js';
import { browserDetection, LitElement } from '@lion/core'; import { browserDetection, LitElement } from '@lion/core';
import { Required } from '@lion/form-core'; import { Required } from '@lion/form-core';
import { LionCombobox } from '../src/LionCombobox.js';
/** /**
* @typedef {import('../src/LionCombobox.js').LionCombobox} LionCombobox * @typedef {import('../src/LionCombobox.js').LionCombobox} LionCombobox
@ -20,7 +21,7 @@ function mimicUserTyping(el, value) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
el._inputNode.value = value; el._inputNode.value = value;
el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
el._overlayInvokerNode.dispatchEvent(new Event('keydown')); el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: value }));
} }
/** /**
@ -31,31 +32,32 @@ async function mimicUserTypingAdvanced(el, values) {
const inputNode = /** @type {HTMLInputElement & {selectionStart:number, selectionEnd:number}} */ (el._inputNode); const inputNode = /** @type {HTMLInputElement & {selectionStart:number, selectionEnd:number}} */ (el._inputNode);
inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); inputNode.dispatchEvent(new Event('focusin', { bubbles: true }));
let hasSelection = inputNode.selectionStart !== inputNode.selectionEnd;
for (const key of values) { for (const key of values) {
// eslint-disable-next-line no-await-in-loop, no-loop-func // eslint-disable-next-line no-await-in-loop, no-loop-func
await new Promise(resolve => { await new Promise(resolve => {
setTimeout(() => { const hasSelection = inputNode.selectionStart !== inputNode.selectionEnd;
if (key === 'Backspace') {
if (hasSelection) { if (key === 'Backspace') {
inputNode.value = if (hasSelection) {
inputNode.value.slice(0, inputNode.selectionStart) +
inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length);
} else {
inputNode.value = inputNode.value.slice(0, -1);
}
} else if (hasSelection) {
inputNode.value = inputNode.value =
inputNode.value.slice(0, inputNode.selectionStart) + inputNode.value.slice(0, inputNode.selectionStart) +
key +
inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length); inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length);
} else { } else {
inputNode.value += key; inputNode.value = inputNode.value.slice(0, -1);
} }
hasSelection = false; } else if (hasSelection) {
inputNode.dispatchEvent(new KeyboardEvent('keydown', { key })); inputNode.value =
el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); inputNode.value.slice(0, inputNode.selectionStart) +
key +
inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length);
} else {
inputNode.value += key;
}
inputNode.dispatchEvent(new KeyboardEvent('keydown', { key }));
el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
el.updateComplete.then(() => {
resolve(); resolve();
}); });
}); });
@ -246,8 +248,8 @@ describe('lion-combobox', () => {
}); });
}); });
describe('Listbox visibility', () => { describe('Overlay visibility', () => {
it('does not show listbox on focusin', async () => { it('does not show overlay on focusin', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html` const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo" multiple-choice> <lion-combobox name="foo" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option> <lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -263,14 +265,14 @@ describe('lion-combobox', () => {
expect(el.opened).to.equal(false); expect(el.opened).to.equal(false);
}); });
it('shows listbox again after select and char keydown', async () => { it('shows overlay again after select and char keydown', async () => {
/** /**
* Scenario: * Scenario:
* [1] user focuses textbox: listbox hidden * [1] user focuses textbox: overlay hidden
* [2] user types char: listbox shows * [2] user types char: overlay shows
* [3] user selects "Artichoke": listbox closes, textbox gets value "Artichoke" and textbox * [3] user selects "Artichoke": overlay closes, textbox gets value "Artichoke" and textbox
* still has focus * still has focus
* [4] user changes textbox value to "Artichoke": the listbox should show again * [4] user changes textbox value to "Artichoke": the overlay should show again
*/ */
const el = /** @type {LionCombobox} */ (await fixture(html` const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo"> <lion-combobox name="foo">
@ -306,7 +308,7 @@ describe('lion-combobox', () => {
expect(el.opened).to.equal(true); expect(el.opened).to.equal(true);
}); });
it('hides (and clears) listbox on [Escape]', async () => { it('hides (and clears) overlay on [Escape]', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html` const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo"> <lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option> <lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -329,7 +331,7 @@ describe('lion-combobox', () => {
expect(el._inputNode.value).to.equal(''); expect(el._inputNode.value).to.equal('');
}); });
it('hides listbox on [Tab]', async () => { it('hides overlay on [Tab]', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html` const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo"> <lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option> <lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -371,13 +373,40 @@ describe('lion-combobox', () => {
expect(el._inputNode.value).to.equal('Artichoke'); expect(el._inputNode.value).to.equal('Artichoke');
expect(el.checkedIndex).to.equal(0); expect(el.checkedIndex).to.equal(0);
el._inputNode.value = '';
mimicUserTyping(el, ''); mimicUserTyping(el, '');
await el.updateComplete;
el.opened = false; el.opened = false;
await el.updateComplete; await el.updateComplete;
expect(el.checkedIndex).to.equal(-1); expect(el.checkedIndex).to.equal(-1);
}); });
// NB: If this becomes a suite, move to separate file
describe('Subclassers', () => {
it('allows to control overlay visibility via "_showOverlayCondition"', async () => {
class ShowOverlayConditionCombobox extends LionCombobox {
_showOverlayCondition(options) {
return this.focused || super.showOverlayCondition(options);
}
}
const tagName = defineCE(ShowOverlayConditionCombobox);
const tag = unsafeStatic(tagName);
const el = /** @type {LionCombobox} */ (await fixture(html`
<${tag} name="foo" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</${tag}>
`));
expect(el.opened).to.equal(false);
el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.opened).to.equal(true);
});
});
describe('Accessibility', () => { describe('Accessibility', () => {
it('sets "aria-posinset" and "aria-setsize" on visible entries', async () => { it('sets "aria-posinset" and "aria-setsize" on visible entries', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html` const el = /** @type {LionCombobox} */ (await fixture(html`
@ -783,7 +812,42 @@ describe('lion-combobox', () => {
expect(el._inputNode.selectionEnd).to.equal('ch'.length); expect(el._inputNode.selectionEnd).to.equal('ch'.length);
}); });
it('does autocompletion when adding chars', async () => { it('synchronizes textbox on overlay close', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo" autocomplete="none">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
expect(el._inputNode.value).to.equal('');
async function performChecks(autocomplete, index, valueOnClose) {
await el.updateComplete;
el.opened = true;
el.setCheckedIndex(-1);
await el.updateComplete;
el.autocomplete = autocomplete;
el.setCheckedIndex(index);
el.opened = false;
await el.updateComplete;
expect(el._inputNode.value).to.equal(valueOnClose);
}
await performChecks('none', 0, 'Artichoke');
await performChecks('list', 0, 'Artichoke');
await performChecks('inline', 0, 'Artichoke');
await performChecks('both', 0, 'Artichoke');
el.multipleChoice = true;
await performChecks('none', [0, 1], '');
await performChecks('list', [0, 1], '');
await performChecks('inline', [0, 1], '');
await performChecks('both', [0, 1], '');
});
it('does inline autocompletion when adding chars', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html` const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo" autocomplete="inline"> <lion-combobox name="foo" autocomplete="inline">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option> <lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -809,7 +873,7 @@ describe('lion-combobox', () => {
expect(el.checkedIndex).to.equal(1); expect(el.checkedIndex).to.equal(1);
}); });
it('does autocompletion when changing the word', async () => { it('does inline autocompletion when changing the word', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html` const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo" autocomplete="inline"> <lion-combobox name="foo" autocomplete="inline">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option> <lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -824,7 +888,7 @@ describe('lion-combobox', () => {
expect(el.activeIndex).to.equal(1); expect(el.activeIndex).to.equal(1);
expect(el.checkedIndex).to.equal(1); expect(el.checkedIndex).to.equal(1);
await mimicUserTypingAdvanced(el, 'ic'.split('')); await mimicUserTypingAdvanced(el, ['i']);
await el.updateComplete; await el.updateComplete;
expect(el.activeIndex).to.equal(2); expect(el.activeIndex).to.equal(2);
expect(el.checkedIndex).to.equal(2); expect(el.checkedIndex).to.equal(2);
@ -849,16 +913,42 @@ describe('lion-combobox', () => {
mimicUserTyping(el, 'ch'); mimicUserTyping(el, 'ch');
await el.updateComplete; await el.updateComplete;
expect(el._inputNode.value).to.equal('Chard'); expect(el._inputNode.value).to.equal('Chard');
expect(el._inputNode.selectionStart).to.equal('ch'.length); expect(el._inputNode.selectionStart).to.equal('Ch'.length);
expect(el._inputNode.selectionEnd).to.equal('Chard'.length); expect(el._inputNode.selectionEnd).to.equal('Chard'.length);
// Autocompletion happened. When we go backwards ('Char'), we should not // Autocompletion happened. When we go backwards ('Ch[ard]' => 'Ch'), we should not
// autocomplete to 'Chard' anymore. // autocomplete to 'Chard' anymore.
mimicUserTyping(el, 'Char'); await mimicUserTypingAdvanced(el, ['Backspace']);
await el.updateComplete; await el.updateComplete;
expect(el._inputNode.value).to.equal('Char'); // so not 'Chard' expect(el._inputNode.value).to.equal('Ch'); // so not 'Chard'
expect(el._inputNode.selectionStart).to.equal('Char'.length); expect(el._inputNode.selectionStart).to.equal('Ch'.length);
expect(el._inputNode.selectionEnd).to.equal('Char'.length); expect(el._inputNode.selectionEnd).to.equal('Ch'.length);
});
describe('Subclassers', () => {
it('allows to configure autoselect', async () => {
class X extends LionCombobox {
_autoSelectCondition() {
return true;
}
}
const tagName = defineCE(X);
const tag = unsafeStatic(tagName);
const el = /** @type {LionCombobox} */ (await fixture(html`
<${tag} name="foo" autocomplete="list" opened>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</${tag}>
`));
// This ensures autocomplete would be off originally
el.autocomplete = 'list';
await mimicUserTypingAdvanced(el, 'vi'); // so we have options ['Victoria Plum']
await el.updateComplete;
expect(el.checkedIndex).to.equal(3);
});
}); });
it('highlights matching options', async () => { it('highlights matching options', async () => {
@ -889,6 +979,125 @@ describe('lion-combobox', () => {
expect(options[3]).lightDom.to.equal(`Victoria Plum`); expect(options[3]).lightDom.to.equal(`Victoria Plum`);
}); });
it('synchronizes textbox when autocomplete is "inline" or "both"', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo" autocomplete="none">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
expect(el._inputNode.value).to.equal('');
el.setCheckedIndex(-1);
el.autocomplete = 'none';
el.setCheckedIndex(0);
expect(el._inputNode.value).to.equal('');
el.setCheckedIndex(-1);
el.autocomplete = 'list';
el.setCheckedIndex(0);
expect(el._inputNode.value).to.equal('');
el.setCheckedIndex(-1);
el.autocomplete = 'inline';
el.setCheckedIndex(0);
expect(el._inputNode.value).to.equal('Artichoke');
el.setCheckedIndex(-1);
el.autocomplete = 'both';
el.setCheckedIndex(0);
expect(el._inputNode.value).to.equal('Artichoke');
});
it('synchronizes last index to textbox when autocomplete is "inline" or "both" when multipleChoice', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html`
<lion-combobox name="foo" autocomplete="none" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
expect(el._inputNode.value).to.eql('');
el.setCheckedIndex(-1);
el.autocomplete = 'none';
el.setCheckedIndex([0]);
el.setCheckedIndex([1]);
expect(el._inputNode.value).to.equal('');
el.setCheckedIndex(-1);
el.autocomplete = 'list';
el.setCheckedIndex([0]);
el.setCheckedIndex([1]);
expect(el._inputNode.value).to.equal('');
el.setCheckedIndex(-1);
el.autocomplete = 'inline';
el.setCheckedIndex([0]);
expect(el._inputNode.value).to.equal('Artichoke');
el.setCheckedIndex([1]);
expect(el._inputNode.value).to.equal('Chard');
el.setCheckedIndex(-1);
el.autocomplete = 'both';
el.setCheckedIndex([0]);
expect(el._inputNode.value).to.equal('Artichoke');
el.setCheckedIndex([1]);
expect(el._inputNode.value).to.equal('Chard');
});
describe('Subclassers', () => {
it('allows to override "_syncCheckedWithTextboxMultiple"', async () => {
class X extends LionCombobox {
// eslint-disable-next-line no-unused-vars
_syncToTextboxCondition() {
return true;
}
// eslint-disable-next-line no-unused-vars
_syncToTextboxMultiple(modelValue, oldModelValue) {
// In a real scenario (depending on how selection display works),
// you could override the default (last selected option) with '' for instance
this._setTextboxValue(`${modelValue}-${oldModelValue}-multi`);
}
}
const tagName = defineCE(X);
const tag = unsafeStatic(tagName);
const el = /** @type {LionCombobox} */ (await fixture(html`
<${tag} name="foo" autocomplete="none" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</${tag}>
`));
el.setCheckedIndex(-1);
el.autocomplete = 'none';
el.setCheckedIndex([0]);
expect(el._inputNode.value).to.equal('Artichoke--multi');
el.setCheckedIndex(-1);
el.autocomplete = 'list';
el.setCheckedIndex([0]);
expect(el._inputNode.value).to.equal('Artichoke--multi');
el.setCheckedIndex(-1);
el.autocomplete = 'inline';
el.setCheckedIndex([0]);
expect(el._inputNode.value).to.equal('Artichoke--multi');
el.setCheckedIndex(-1);
el.autocomplete = 'both';
el.setCheckedIndex([0]);
expect(el._inputNode.value).to.equal('Artichoke--multi');
});
});
describe('Active index behavior', () => { describe('Active index behavior', () => {
it('sets the active index to the closest match on open by default', async () => { it('sets the active index to the closest match on open by default', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html` const el = /** @type {LionCombobox} */ (await fixture(html`
@ -919,9 +1128,19 @@ describe('lion-combobox', () => {
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option> <lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox> </lion-combobox>
`)); `));
/** @param {LionCombobox} elm */
function reset(elm) {
// eslint-disable-next-line no-param-reassign
elm.activeIndex = -1;
// eslint-disable-next-line no-param-reassign
elm.checkedIndex = -1;
}
// https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html
// Example 1. List Autocomplete with Manual Selection: // Example 1. List Autocomplete with Manual Selection:
// does not set active at all until user selects // does not set active at all until user selects
reset(el);
el.autocomplete = 'none'; el.autocomplete = 'none';
mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha');
await el.updateComplete; await el.updateComplete;
@ -932,6 +1151,7 @@ describe('lion-combobox', () => {
// https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html
// Example 2. List Autocomplete with Automatic Selection: // Example 2. List Autocomplete with Automatic Selection:
// does not set active at all until user selects // does not set active at all until user selects
reset(el);
el.autocomplete = 'list'; el.autocomplete = 'list';
mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha');
await el.updateComplete; await el.updateComplete;
@ -941,6 +1161,7 @@ describe('lion-combobox', () => {
// https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // 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") // Example 3. List with Inline Autocomplete (mostly, but with aria-autocomplete="inline")
reset(el);
el.autocomplete = 'inline'; el.autocomplete = 'inline';
mimicUserTyping(/** @type {LionCombobox} */ (el), ''); mimicUserTyping(/** @type {LionCombobox} */ (el), '');
await el.updateComplete; await el.updateComplete;
@ -948,18 +1169,18 @@ describe('lion-combobox', () => {
await el.updateComplete; await el.updateComplete;
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); expect(el.activeIndex).to.equal(1);
el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
await el.updateComplete; await el.updateComplete;
await el.updateComplete;
expect(el.activeIndex).to.equal(-1); expect(el.activeIndex).to.equal(-1);
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
// https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html
// Example 3. List with Inline Autocomplete // Example 3. List with Inline Autocomplete
reset(el);
el.autocomplete = 'both'; el.autocomplete = 'both';
mimicUserTyping(/** @type {LionCombobox} */ (el), ''); mimicUserTyping(/** @type {LionCombobox} */ (el), '');
await el.updateComplete; await el.updateComplete;
@ -1022,6 +1243,7 @@ describe('lion-combobox', () => {
el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await el.updateComplete; await el.updateComplete;
expect(el._inputNode.textContent).to.equal(''); expect(el._inputNode.textContent).to.equal('');
el.formElements.forEach(option => expect(option.active).to.be.false); el.formElements.forEach(option => expect(option.active).to.be.false);
// change selection, active index should update to closest match // change selection, active index should update to closest match

View file

@ -348,7 +348,7 @@ const ListboxMixinImplementation = superclass =>
this._uncheckChildren(this.formElements.filter(i => i === index)); this._uncheckChildren(this.formElements.filter(i => i === index));
index.forEach(i => { index.forEach(i => {
if (this.formElements[i]) { if (this.formElements[i]) {
this.formElements[i].checked = true; this.formElements[i].checked = !this.formElements[i].checked;
} }
}); });
return; return;
@ -359,7 +359,11 @@ const ListboxMixinImplementation = superclass =>
this._uncheckChildren(); this._uncheckChildren();
} }
if (this.formElements[index]) { if (this.formElements[index]) {
this.formElements[index].checked = true; if (this.multipleChoice) {
this.formElements[index].checked = !this.formElements[index].checked;
} else {
this.formElements[index].checked = true;
}
} }
} }
} }

View file

@ -370,7 +370,7 @@ export function runListboxMixinSuite(customConfig = {}) {
it('has a reference to the active option', async () => { it('has a reference to the active option', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} opened has-no-default-selected autocomplete="list"> <${tag} opened has-no-default-selected autocomplete="none">
<${optionTag} .choiceValue=${'10'} id="first">Item 1</${optionTag}> <${optionTag} .choiceValue=${'10'} id="first">Item 1</${optionTag}>
<${optionTag} .choiceValue=${'20'} checked id="second">Item 2</${optionTag}> <${optionTag} .choiceValue=${'20'} checked id="second">Item 2</${optionTag}>
</${tag}> </${tag}>
@ -382,10 +382,6 @@ export function runListboxMixinSuite(customConfig = {}) {
// Normalize // Normalize
el.activeIndex = 0; el.activeIndex = 0;
// el._activeDescendantOwnerNode.dispatchEvent(
// new KeyboardEvent('keydown', { key: 'ArrowDown' }),
// );
await el.updateComplete; await el.updateComplete;
expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('first'); expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('first');
activeDescendantOwner.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); activeDescendantOwner.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
@ -829,6 +825,10 @@ export function runListboxMixinSuite(customConfig = {}) {
options[1].click(); options[1].click();
expect(options[0].checked).to.equal(true); expect(options[0].checked).to.equal(true);
expect(el.modelValue).to.eql(['Artichoke', 'Chard']); expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
// also deselect
options[1].click();
expect(options[0].checked).to.equal(true);
expect(el.modelValue).to.eql(['Artichoke']);
// Reset // Reset
// @ts-ignore allow protected members in tests // @ts-ignore allow protected members in tests
@ -841,6 +841,10 @@ export function runListboxMixinSuite(customConfig = {}) {
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(options[0].checked).to.equal(true); expect(options[0].checked).to.equal(true);
expect(el.modelValue).to.eql(['Artichoke', 'Chard']); expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
// also deselect
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(options[0].checked).to.equal(true);
expect(el.modelValue).to.eql(['Artichoke']);
// @ts-ignore allow protected // @ts-ignore allow protected
if (el._listboxReceivesNoFocus) { if (el._listboxReceivesNoFocus) {
@ -858,6 +862,10 @@ export function runListboxMixinSuite(customConfig = {}) {
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(options[0].checked).to.equal(true); expect(options[0].checked).to.equal(true);
expect(el.modelValue).to.eql(['Artichoke', 'Chard']); expect(el.modelValue).to.eql(['Artichoke', 'Chard']);
// also deselect
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(options[0].checked).to.equal(true);
expect(el.modelValue).to.eql(['Artichoke']);
}); });
describe('Accessibility', () => { describe('Accessibility', () => {