feat(combobox): improvements api, demos and autocompletion ux
This commit is contained in:
parent
c03ebde5b5
commit
0ebca5b47d
7 changed files with 491 additions and 51 deletions
11
.changeset/heavy-ghosts-sell.md
Normal file
11
.changeset/heavy-ghosts-sell.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
'@lion/combobox': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Combobox api, demo and ux improvements
|
||||||
|
|
||||||
|
- renamed `filterOptionCondition ` (similarity to `match-mode`, since this is basically an override)
|
||||||
|
- demos for `matchCondition`
|
||||||
|
- inline autocompletion edge cases solved (that would be inconsistent ux otherwise)
|
||||||
|
- demos took a long time render: introduced a lazyRender directive that only adds (expensive) lionOptions after first meaningful paint has happened
|
||||||
|
- made clearer from the code that selectionDisplay component is for demo purposes only at this moment
|
||||||
|
|
@ -19,7 +19,9 @@ import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
|
||||||
import { listboxData } from '@lion/listbox/docs/listboxData.js';
|
import { listboxData } from '@lion/listbox/docs/listboxData.js';
|
||||||
import '@lion/listbox/lion-option.js';
|
import '@lion/listbox/lion-option.js';
|
||||||
import './lion-combobox.js';
|
import './lion-combobox.js';
|
||||||
import './docs/lion-combobox-selection-display.js';
|
import './docs/demo-selection-display.js';
|
||||||
|
import { lazyRender } from './docs/lazyRender.js';
|
||||||
|
import levenshtein from './docs/levenshtein.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Forms/Combobox',
|
title: 'Forms/Combobox',
|
||||||
|
|
@ -29,7 +31,9 @@ export default {
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const main = () => html`
|
export const main = () => html`
|
||||||
<lion-combobox name="combo" label="Default">
|
<lion-combobox name="combo" label="Default">
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -47,7 +51,7 @@ to the configurable values `none`, `list`, `inline` and `both`.
|
||||||
| both | ✓ | ✓ | ✓ | ✓ | ✓ |
|
| both | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
- **list** shows a list on keydown character press
|
- **list** shows a list on keydown character press
|
||||||
- **filter** filters list of potential matches according to `matchmode` or provided `filterOptionCondition`
|
- **filter** filters list of potential matches according to `matchmode` or provided `matchCondition`
|
||||||
- **focus** automatically focuses closest match (makes it the activedescendant)
|
- **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)
|
- **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)
|
- **complete** completes the textbox value inline (the 'missing characters' will be added as selected text)
|
||||||
|
|
@ -59,7 +63,9 @@ Selection will happen manually by the user.
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const autocompleteNone = () => html`
|
export const autocompleteNone = () => html`
|
||||||
<lion-combobox name="combo" label="Autocomplete 'none'" autocomplete="none">
|
<lion-combobox name="combo" label="Autocomplete 'none'" autocomplete="none">
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -69,7 +75,9 @@ When `autocomplete="list"` is configured, it will filter listbox suggestions bas
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const autocompleteList = () => html`
|
export const autocompleteList = () => html`
|
||||||
<lion-combobox name="combo" label="Autocomplete 'list'" autocomplete="list">
|
<lion-combobox name="combo" label="Autocomplete 'list'" autocomplete="list">
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -80,7 +88,9 @@ It does NOT filter list of potential matches.
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const autocompleteInline = () => html`
|
export const autocompleteInline = () => html`
|
||||||
<lion-combobox name="combo" label="Autocomplete 'inline'" autocomplete="inline">
|
<lion-combobox name="combo" label="Autocomplete 'inline'" autocomplete="inline">
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -91,7 +101,9 @@ This is the default value for `autocomplete`.
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const autocompleteBoth = () => html`
|
export const autocompleteBoth = () => html`
|
||||||
<lion-combobox name="combo" label="Autocomplete 'both'" autocomplete="both">
|
<lion-combobox name="combo" label="Autocomplete 'both'" autocomplete="both">
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -107,7 +119,9 @@ So 'ch' will both match 'Chard' and 'Artichoke'.
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const matchModeBegin = () => html`
|
export const matchModeBegin = () => html`
|
||||||
<lion-combobox name="combo" label="Match Mode 'begin'" match-mode="begin">
|
<lion-combobox name="combo" label="Match Mode 'begin'" match-mode="begin">
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -115,7 +129,33 @@ export const matchModeBegin = () => html`
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const matchModeAll = () => html`
|
export const matchModeAll = () => html`
|
||||||
<lion-combobox name="combo" label="Match Mode 'all'" match-mode="all">
|
<lion-combobox name="combo" label="Match Mode 'all'" match-mode="all">
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
When the preconfigurable `match-mode` conditions are not sufficient,
|
||||||
|
one can define a custom matching function.
|
||||||
|
The example below matches when the Levenshtein distance is below 3 (including some other conditions).
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const customMatchCondition = () => html`
|
||||||
|
<lion-combobox
|
||||||
|
name="combo"
|
||||||
|
label="Custom Match Mode 'levenshtein'"
|
||||||
|
help-text="Spelling mistakes will be forgiven. Try typing 'Aple' instead of 'Apple'"
|
||||||
|
.matchCondition="${({ choiceValue }, textboxValue) => {
|
||||||
|
const oVal = choiceValue.toLowerCase();
|
||||||
|
const tVal = textboxValue.toLowerCase();
|
||||||
|
const t = 1; // treshold
|
||||||
|
return oVal.slice(0, t) === tVal.slice(0, t) && levenshtein(oVal, tVal) < 3;
|
||||||
|
}}"
|
||||||
|
>
|
||||||
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -133,7 +173,9 @@ will be kept track of independently.
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const noSelectionFollowsFocus = () => html`
|
export const noSelectionFollowsFocus = () => html`
|
||||||
<lion-combobox name="combo" label="No Selection Follows focus" .selectionFollowsFocus="${false}">
|
<lion-combobox name="combo" label="No Selection Follows focus" .selectionFollowsFocus="${false}">
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -149,7 +191,9 @@ export const noRotateKeyboardNavigation = () => html`
|
||||||
label="No Rotate Keyboard Navigation"
|
label="No Rotate Keyboard Navigation"
|
||||||
.rotateKeyboardNavigation="${false}"
|
.rotateKeyboardNavigation="${false}"
|
||||||
>
|
>
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
@ -170,10 +214,12 @@ This will:
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const multipleChoice = () => html`
|
export const multipleChoice = () => html`
|
||||||
<lion-combobox name="combo" label="Multiple" multiple-choice>
|
<lion-combobox name="combo" label="Multiple" multiple-choice>
|
||||||
<lion-combobox-selection-display slot="selection-display"></lion-combobox-selection-display>
|
<demo-selection-display slot="selection-display"></demo-selection-display>
|
||||||
${listboxData.map(
|
${lazyRender(
|
||||||
(entry, i) =>
|
listboxData.map(
|
||||||
html` <lion-option .choiceValue="${entry}" ?checked=${i === 0}>${entry}</lion-option> `,
|
(entry, i) =>
|
||||||
|
html` <lion-option .choiceValue="${entry}" ?checked=${i === 0}>${entry}</lion-option> `,
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
|
|
@ -193,7 +239,9 @@ export const invokerButton = () => html`
|
||||||
}}"
|
}}"
|
||||||
>
|
>
|
||||||
<button slot="suffix" type="button" tabindex="-1">▼</button>
|
<button slot="suffix" type="button" tabindex="-1">▼</button>
|
||||||
${listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `)}
|
${lazyRender(
|
||||||
|
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||||
|
)}
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
|
||||||
191
packages/combobox/docs/demo-selection-display.js
Normal file
191
packages/combobox/docs/demo-selection-display.js
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
// 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 DemoSelectionDisplay 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();
|
||||||
|
|
||||||
|
this.selectedElements = [];
|
||||||
|
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this.__textboxOnKeyup = this.__textboxOnKeyup.bind(this);
|
||||||
|
/** @type {EventListener} */
|
||||||
|
this.__restoreBackspace = this.__restoreBackspace.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
|
*/
|
||||||
|
firstUpdated(changedProperties) {
|
||||||
|
super.firstUpdated(changedProperties);
|
||||||
|
|
||||||
|
if (this.multipleChoice) {
|
||||||
|
this._inputNode.addEventListener('keyup', this.__textboxOnKeyup);
|
||||||
|
this._inputNode.addEventListener('focusout', this.__restoreBackspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('lit-element').PropertyValues } changedProperties
|
||||||
|
*/
|
||||||
|
onComboboxElementUpdated(changedProperties) {
|
||||||
|
if (changedProperties.has('modelValue')) {
|
||||||
|
this.selectedElements = this._computeSelectedElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whenever selectedElements are updated, makes sure that latest added elements
|
||||||
|
* are shown latest, and deleted elements respect existing order of chips.
|
||||||
|
*/
|
||||||
|
__reorderChips() {
|
||||||
|
const { selectedElements } = this;
|
||||||
|
if (this.__prevSelectedEls) {
|
||||||
|
const addedEls = selectedElements.filter(e => !this.__prevSelectedEls.includes(e));
|
||||||
|
const deletedEls = this.__prevSelectedEls.filter(e => !selectedElements.includes(e));
|
||||||
|
if (addedEls.length) {
|
||||||
|
this.selectedElements = [...this.__prevSelectedEls, ...addedEls];
|
||||||
|
} else if (deletedEls.length) {
|
||||||
|
deletedEls.forEach(delEl => {
|
||||||
|
this.__prevSelectedEls.splice(this.__prevSelectedEls.indexOf(delEl), 1);
|
||||||
|
});
|
||||||
|
this.selectedElements = this.__prevSelectedEls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.__prevSelectedEls = this.selectedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("@lion/listbox").LionOption} option
|
||||||
|
* @param {boolean} highlight
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
_selectedElementTemplate(option, highlight) {
|
||||||
|
return html`
|
||||||
|
<span class="selection-chip ${highlight ? 'selection-chip--highlighted' : ''}">
|
||||||
|
${option.value}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedElementsTemplate() {
|
||||||
|
if (!this.multipleChoice) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div class="combobox__selection">
|
||||||
|
${this.selectedElements.map((option, i) => {
|
||||||
|
const highlight = Boolean(
|
||||||
|
this.removeChipOnNextBackspace && i === this.selectedElements.length - 1,
|
||||||
|
);
|
||||||
|
return this._selectedElementTemplate(option, highlight);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html` ${this._selectedElementsTemplate()} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ key: string; }} ev
|
||||||
|
*/
|
||||||
|
__textboxOnKeyup(ev) {
|
||||||
|
if (ev.key === 'Backspace') {
|
||||||
|
if (!this._inputNode.value) {
|
||||||
|
if (this.removeChipOnNextBackspace && this.selectedElements.length) {
|
||||||
|
this.selectedElements[this.selectedElements.length - 1].checked = false;
|
||||||
|
}
|
||||||
|
this.removeChipOnNextBackspace = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.removeChipOnNextBackspace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__restoreBackspace() {
|
||||||
|
this.removeChipOnNextBackspace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('demo-selection-display', DemoSelectionDisplay);
|
||||||
23
packages/combobox/docs/lazyRender.js
Normal file
23
packages/combobox/docs/lazyRender.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { directive } from '@lion/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In order to speed up the first meaningful paint, use this directive
|
||||||
|
* on content that is:
|
||||||
|
* - (visually) hidden
|
||||||
|
* - out of the page flow (having position: 'absolute|fixed')
|
||||||
|
*
|
||||||
|
* A good practice would be to use it in overlays,
|
||||||
|
* For hidden tab panels, collapsible content etc. it's also useful
|
||||||
|
* @example
|
||||||
|
* <lion-combobox name="combo" label="Combo">
|
||||||
|
* ${lazyRender(
|
||||||
|
* largeListOfData.map(entry => html` <expensive-option>${entry}</expensive-option> `),
|
||||||
|
* )}
|
||||||
|
* </lion-combobox>
|
||||||
|
*/
|
||||||
|
export const lazyRender = directive(tplResult => part => {
|
||||||
|
setTimeout(() => {
|
||||||
|
part.setValue(tplResult);
|
||||||
|
part.commit();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
packages/combobox/docs/levenshtein.js
Normal file
95
packages/combobox/docs/levenshtein.js
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/* eslint-disable*/
|
||||||
|
// https://github.com/gustf/js-levenshtein/blob/master/index.js
|
||||||
|
|
||||||
|
function _min(d0, d1, d2, bx, ay) {
|
||||||
|
return d0 < d1 || d2 < d1 ? (d0 > d2 ? d2 + 1 : d0 + 1) : bx === ay ? d1 : d1 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (a, b) {
|
||||||
|
if (a === b) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.length > b.length) {
|
||||||
|
var tmp = a;
|
||||||
|
a = b;
|
||||||
|
b = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
var la = a.length;
|
||||||
|
var lb = b.length;
|
||||||
|
|
||||||
|
while (la > 0 && a.charCodeAt(la - 1) === b.charCodeAt(lb - 1)) {
|
||||||
|
la--;
|
||||||
|
lb--;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
while (offset < la && a.charCodeAt(offset) === b.charCodeAt(offset)) {
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
la -= offset;
|
||||||
|
lb -= offset;
|
||||||
|
|
||||||
|
if (la === 0 || lb < 3) {
|
||||||
|
return lb;
|
||||||
|
}
|
||||||
|
|
||||||
|
var x = 0;
|
||||||
|
var y;
|
||||||
|
var d0;
|
||||||
|
var d1;
|
||||||
|
var d2;
|
||||||
|
var d3;
|
||||||
|
var dd;
|
||||||
|
var dy;
|
||||||
|
var ay;
|
||||||
|
var bx0;
|
||||||
|
var bx1;
|
||||||
|
var bx2;
|
||||||
|
var bx3;
|
||||||
|
|
||||||
|
var vector = [];
|
||||||
|
|
||||||
|
for (y = 0; y < la; y++) {
|
||||||
|
vector.push(y + 1);
|
||||||
|
vector.push(a.charCodeAt(offset + y));
|
||||||
|
}
|
||||||
|
|
||||||
|
var len = vector.length - 1;
|
||||||
|
|
||||||
|
for (; x < lb - 3; ) {
|
||||||
|
bx0 = b.charCodeAt(offset + (d0 = x));
|
||||||
|
bx1 = b.charCodeAt(offset + (d1 = x + 1));
|
||||||
|
bx2 = b.charCodeAt(offset + (d2 = x + 2));
|
||||||
|
bx3 = b.charCodeAt(offset + (d3 = x + 3));
|
||||||
|
dd = x += 4;
|
||||||
|
for (y = 0; y < len; y += 2) {
|
||||||
|
dy = vector[y];
|
||||||
|
ay = vector[y + 1];
|
||||||
|
d0 = _min(dy, d0, d1, bx0, ay);
|
||||||
|
d1 = _min(d0, d1, d2, bx1, ay);
|
||||||
|
d2 = _min(d1, d2, d3, bx2, ay);
|
||||||
|
dd = _min(d2, d3, dd, bx3, ay);
|
||||||
|
vector[y] = dd;
|
||||||
|
d3 = d2;
|
||||||
|
d2 = d1;
|
||||||
|
d1 = d0;
|
||||||
|
d0 = dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (; x < lb; ) {
|
||||||
|
bx0 = b.charCodeAt(offset + (d0 = x));
|
||||||
|
dd = ++x;
|
||||||
|
for (y = 0; y < len; y += 2) {
|
||||||
|
dy = vector[y];
|
||||||
|
vector[y] = dd = _min(dy, d0, dd, bx0, vector[y + 1]);
|
||||||
|
d0 = dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dd;
|
||||||
|
}
|
||||||
|
|
@ -246,6 +246,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
this._listboxReceivesNoFocus = true;
|
this._listboxReceivesNoFocus = true;
|
||||||
|
|
||||||
this.__prevCboxValueNonSelected = '';
|
this.__prevCboxValueNonSelected = '';
|
||||||
|
this.__prevCboxValue = '';
|
||||||
|
|
||||||
/** @type {EventListener} */
|
/** @type {EventListener} */
|
||||||
this.__showOverlay = this.__showOverlay.bind(this);
|
this.__showOverlay = this.__showOverlay.bind(this);
|
||||||
|
|
@ -307,27 +308,27 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
this.__shouldAutocompleteNextUpdate
|
this.__shouldAutocompleteNextUpdate
|
||||||
) {
|
) {
|
||||||
// Only update list in render cycle
|
// Only update list in render cycle
|
||||||
this._handleAutocompletion({
|
this._handleAutocompletion();
|
||||||
curValue: this._inputNode.value,
|
|
||||||
prevValue: this.__prevCboxValueNonSelected,
|
|
||||||
});
|
|
||||||
this.__shouldAutocompleteNextUpdate = false;
|
this.__shouldAutocompleteNextUpdate = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._selectionDisplayNode) {
|
if (typeof this._selectionDisplayNode?.onComboboxElementUpdated === 'function') {
|
||||||
this._selectionDisplayNode.onComboboxElementUpdated(changedProperties);
|
this._selectionDisplayNode.onComboboxElementUpdated(changedProperties);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* When the preconfigurable `match-mode` conditions are not sufficient,
|
||||||
|
* one can define a custom matching function.
|
||||||
|
*
|
||||||
* @overridable
|
* @overridable
|
||||||
* @param {LionOption} option
|
* @param {LionOption} option
|
||||||
* @param {string} curValue current ._inputNode value
|
* @param {string} textboxValue current ._inputNode value
|
||||||
*/
|
*/
|
||||||
filterOptionCondition(option, curValue) {
|
matchCondition(option, textboxValue) {
|
||||||
let idx = -1;
|
let idx = -1;
|
||||||
if (typeof option.choiceValue === 'string' && typeof curValue === 'string') {
|
if (typeof option.choiceValue === 'string' && typeof textboxValue === 'string') {
|
||||||
idx = option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase());
|
idx = option.choiceValue.toLowerCase().indexOf(textboxValue.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.matchMode === 'all') {
|
if (this.matchMode === 'all') {
|
||||||
|
|
@ -353,6 +354,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -424,9 +426,16 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
option.style.display = 'none';
|
option.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @overridable whether a user int
|
||||||
|
*/
|
||||||
_computeUserIntendsAutoFill({ prevValue, curValue }) {
|
_computeUserIntendsAutoFill({ prevValue, curValue }) {
|
||||||
const userIsAddingChars = prevValue.length < curValue.length;
|
const userIsAddingChars = prevValue.length < curValue.length;
|
||||||
const userStartsNewWord = prevValue.length && curValue.length && prevValue[0] !== curValue[0];
|
const userStartsNewWord =
|
||||||
|
prevValue.length &&
|
||||||
|
curValue.length &&
|
||||||
|
prevValue[0].toLowerCase() !== curValue[0].toLowerCase();
|
||||||
return userIsAddingChars || userStartsNewWord;
|
return userIsAddingChars || userStartsNewWord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,15 +443,15 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches visibility of listbox options against current ._inputNode contents
|
* 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 }) {
|
_handleAutocompletion() {
|
||||||
if (this.autocomplete === 'none') {
|
if (this.autocomplete === 'none') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const curValue = this._inputNode.value;
|
||||||
|
const prevValue = this.__hasSelection ? this.__prevCboxValueNonSelected : this.__prevCboxValue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The filtered list of options that will match in this autocompletion cycle
|
* The filtered list of options that will match in this autocompletion cycle
|
||||||
* @type {LionOption[]}
|
* @type {LionOption[]}
|
||||||
|
|
@ -454,8 +463,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
|
|
||||||
/** @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) => {
|
||||||
const show =
|
const show = this.autocomplete === 'inline' ? true : this.matchCondition(option, curValue);
|
||||||
this.autocomplete === 'inline' ? true : this.filterOptionCondition(option, curValue);
|
|
||||||
|
|
||||||
// [1]. Synchronize ._inputNode value and active descendant with closest match
|
// [1]. Synchronize ._inputNode value and active descendant with closest match
|
||||||
if (isAutoFillCandidate) {
|
if (isAutoFillCandidate) {
|
||||||
|
|
@ -515,8 +523,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
option.removeAttribute('aria-hidden');
|
option.removeAttribute('aria-hidden');
|
||||||
});
|
});
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
const { selectionStart } = this._inputNode;
|
|
||||||
this.__prevCboxValueNonSelected = curValue.slice(0, selectionStart);
|
this.__prevCboxValueNonSelected = curValue;
|
||||||
|
// See test "computation of "user intends autofill" works correctly afer autofill"
|
||||||
|
this.__prevCboxValue = this._inputNode.value;
|
||||||
|
this.__hasSelection = hasAutoFilled;
|
||||||
|
|
||||||
if (this._overlayCtrl && this._overlayCtrl._popper) {
|
if (this._overlayCtrl && this._overlayCtrl._popper) {
|
||||||
this._overlayCtrl._popper.update();
|
this._overlayCtrl._popper.update();
|
||||||
|
|
@ -600,10 +611,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
}
|
}
|
||||||
|
|
||||||
__initFilterListbox() {
|
__initFilterListbox() {
|
||||||
this._handleAutocompletion({
|
this._handleAutocompletion();
|
||||||
curValue: this._inputNode.value,
|
|
||||||
prevValue: this.__prevCboxValueNonSelected,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
__setComboboxDisabledAndReadOnly() {
|
__setComboboxDisabledAndReadOnly() {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,45 @@ function mimicUserTyping(el, value) {
|
||||||
el._overlayInvokerNode.dispatchEvent(new Event('keydown'));
|
el._overlayInvokerNode.dispatchEvent(new Event('keydown'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {LionCombobox} el
|
||||||
|
* @param {string[]} value
|
||||||
|
*/
|
||||||
|
async function mimicUserTypingAdvanced(el, values) {
|
||||||
|
const inputNode = el._inputNode;
|
||||||
|
inputNode.dispatchEvent(new Event('focusin', { bubbles: true }));
|
||||||
|
|
||||||
|
let hasSelection = inputNode.selectionStart !== inputNode.selectionEnd;
|
||||||
|
|
||||||
|
for (const key of values) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop, no-loop-func
|
||||||
|
await new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (key === 'Backspace') {
|
||||||
|
if (hasSelection) {
|
||||||
|
inputNode.value =
|
||||||
|
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.slice(0, inputNode.selectionStart) +
|
||||||
|
key +
|
||||||
|
inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length);
|
||||||
|
} else {
|
||||||
|
inputNode.value += key;
|
||||||
|
}
|
||||||
|
hasSelection = false;
|
||||||
|
inputNode.dispatchEvent(new KeyboardEvent('keydown', { key }));
|
||||||
|
el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {LionCombobox} el
|
* @param {LionCombobox} el
|
||||||
*/
|
*/
|
||||||
|
|
@ -589,7 +628,7 @@ describe('lion-combobox', () => {
|
||||||
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);
|
||||||
|
|
||||||
mimicUserTyping(el, 'chic');
|
await mimicUserTypingAdvanced(el, ['i', 'c']);
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(el._inputNode.value).to.equal('Chicory');
|
expect(el._inputNode.value).to.equal('Chicory');
|
||||||
expect(el._inputNode.selectionStart).to.equal('chic'.length);
|
expect(el._inputNode.selectionStart).to.equal('chic'.length);
|
||||||
|
|
@ -599,8 +638,8 @@ describe('lion-combobox', () => {
|
||||||
mimicUserTyping(el, 'ch');
|
mimicUserTyping(el, 'ch');
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(el._inputNode.value).to.equal('ch');
|
expect(el._inputNode.value).to.equal('ch');
|
||||||
expect(el._inputNode.selectionStart).to.equal(2);
|
expect(el._inputNode.selectionStart).to.equal('ch'.length);
|
||||||
expect(el._inputNode.selectionEnd).to.equal(2);
|
expect(el._inputNode.selectionEnd).to.equal('ch'.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does autocompletion when adding chars', async () => {
|
it('does autocompletion when adding chars', async () => {
|
||||||
|
|
@ -613,20 +652,20 @@ describe('lion-combobox', () => {
|
||||||
</lion-combobox>
|
</lion-combobox>
|
||||||
`));
|
`));
|
||||||
|
|
||||||
mimicUserTyping(el, 'ch');
|
mimicUserTyping(el, 'ch'); // ch
|
||||||
await el.updateComplete;
|
await el.updateComplete; // Ch[ard]
|
||||||
expect(el.activeIndex).to.equal(1);
|
expect(el.activeIndex).to.equal(1);
|
||||||
expect(el.checkedIndex).to.equal(1);
|
expect(el.checkedIndex).to.equal(1);
|
||||||
|
|
||||||
mimicUserTyping(el, 'chic');
|
await mimicUserTypingAdvanced(el, ['i', 'c']); // Chic
|
||||||
await el.updateComplete;
|
await el.updateComplete; // Chic[ory]
|
||||||
expect(el.activeIndex).to.equal(2);
|
expect(el.activeIndex).to.equal(2);
|
||||||
expect(el.checkedIndex).to.equal(2);
|
expect(el.checkedIndex).to.equal(2);
|
||||||
|
|
||||||
mimicUserTyping(el, 'ch');
|
await mimicUserTypingAdvanced(el, ['Backspace', 'Backspace', 'Backspace', 'Backspace', 'h']); // Ch
|
||||||
await el.updateComplete;
|
await el.updateComplete; // Ch[ard]
|
||||||
expect(el.activeIndex).to.equal(2);
|
expect(el.activeIndex).to.equal(1);
|
||||||
expect(el.checkedIndex).to.equal(-1);
|
expect(el.checkedIndex).to.equal(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does autocompletion when changing the word', async () => {
|
it('does autocompletion when changing the word', async () => {
|
||||||
|
|
@ -644,7 +683,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);
|
||||||
|
|
||||||
mimicUserTyping(el, 'chic');
|
await mimicUserTypingAdvanced(el, 'ic'.split(''));
|
||||||
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);
|
||||||
|
|
@ -656,6 +695,31 @@ describe('lion-combobox', () => {
|
||||||
expect(el.checkedIndex).to.equal(0);
|
expect(el.checkedIndex).to.equal(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('computation of "user intends autofill" works correctly afer autofill', async () => {
|
||||||
|
const el = /** @type {LionCombobox} */ (await fixture(html`
|
||||||
|
<lion-combobox name="foo" autocomplete="inline">
|
||||||
|
<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>
|
||||||
|
`));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Autocompletion happened. When we go backwards ('Char'), we should not
|
||||||
|
// autocomplete to 'Chard' anymore.
|
||||||
|
mimicUserTyping(el, 'Char');
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el._inputNode.value).to.equal('Char'); // so not 'Chard'
|
||||||
|
expect(el._inputNode.selectionStart).to.equal('Char'.length);
|
||||||
|
expect(el._inputNode.selectionEnd).to.equal('Char'.length);
|
||||||
|
});
|
||||||
|
|
||||||
it('highlights matching options', async () => {
|
it('highlights matching options', async () => {
|
||||||
const el = /** @type {LionCombobox} */ (await fixture(html`
|
const el = /** @type {LionCombobox} */ (await fixture(html`
|
||||||
<lion-combobox name="foo" match-mode="all">
|
<lion-combobox name="foo" match-mode="all">
|
||||||
|
|
@ -1008,7 +1072,7 @@ describe('lion-combobox', () => {
|
||||||
function onlyExactMatches(option, curValue) {
|
function onlyExactMatches(option, curValue) {
|
||||||
return option.value === curValue;
|
return option.value === curValue;
|
||||||
}
|
}
|
||||||
el.filterOptionCondition = onlyExactMatches;
|
el.matchCondition = onlyExactMatches;
|
||||||
mimicUserTyping(/** @type {LionCombobox} */ (el), 'Chicory');
|
mimicUserTyping(/** @type {LionCombobox} */ (el), 'Chicory');
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql(['Chicory']);
|
expect(getFilteredOptionValues(/** @type {LionCombobox} */ (el))).to.eql(['Chicory']);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue