feat(listbox): _onListboxContenChanged hook server side autocompletion
Co-authored-by: palash2601 <palash2601@gmail.com>
This commit is contained in:
parent
d94d6bd84a
commit
6aa7fc29c8
7 changed files with 215 additions and 12 deletions
5
.changeset/long-foxes-smash.md
Normal file
5
.changeset/long-foxes-smash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/combobox': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
automatically recompute autocompletion features when options change (needed for server side completion support)
|
||||||
|
|
@ -13,7 +13,7 @@ availability of the popup.
|
||||||
> Fore more information, consult [Combobox wai-aria design pattern](https://www.w3.org/TR/wai-aria-practices/#combobox)
|
> Fore more information, consult [Combobox wai-aria design pattern](https://www.w3.org/TR/wai-aria-practices/#combobox)
|
||||||
|
|
||||||
```js script
|
```js script
|
||||||
import { html } from '@lion/core';
|
import { LitElement, html, repeat } from '@lion/core';
|
||||||
import { listboxData } from '../../../../packages/listbox/docs/listboxData.js';
|
import { listboxData } from '../../../../packages/listbox/docs/listboxData.js';
|
||||||
import '@lion/listbox/define';
|
import '@lion/listbox/define';
|
||||||
import '@lion/combobox/define';
|
import '@lion/combobox/define';
|
||||||
|
|
@ -250,6 +250,82 @@ export const invokerButton = () => html`
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Server interaction
|
||||||
|
|
||||||
|
It's possible to fetch data from server side.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
const comboboxData = ['lorem', 'ipsum', 'dolor', 'sit', 'amet'];
|
||||||
|
let rejectPrev;
|
||||||
|
/**
|
||||||
|
* @param {string} val
|
||||||
|
*/
|
||||||
|
function fetchMyData(val) {
|
||||||
|
if (rejectPrev) {
|
||||||
|
rejectPrev();
|
||||||
|
}
|
||||||
|
const results = comboboxData.filter(item => item.toLowerCase().includes(val.toLowerCase()));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
rejectPrev = reject;
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(results);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class DemoServerSide extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return { options: { type: Array } };
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.options = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get combobox() {
|
||||||
|
return /** @type {LionCombobox} */ (this.shadowRoot?.querySelector('#combobox'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {InputEvent & {target: HTMLInputElement}} e
|
||||||
|
*/
|
||||||
|
async fetchMyDataAndRender(e) {
|
||||||
|
this.loading = true;
|
||||||
|
this.requestUpdate();
|
||||||
|
try {
|
||||||
|
this.options = await fetchMyData(e.target.value);
|
||||||
|
this.loading = false;
|
||||||
|
this.requestUpdate();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<lion-combobox
|
||||||
|
.showAllOnEmpty="${true}"
|
||||||
|
id="combobox"
|
||||||
|
@input="${this.fetchMyDataAndRender}"
|
||||||
|
.helpText="Returned from server: [${this.options.join(', ')}]"
|
||||||
|
>
|
||||||
|
<label slot="label" aria-live="polite"
|
||||||
|
>Server side completion ${this.loading
|
||||||
|
? html`<span style="font-style: italic;">(loading...)</span>`
|
||||||
|
: ''}</label
|
||||||
|
>
|
||||||
|
${repeat(
|
||||||
|
this.options,
|
||||||
|
entry => entry,
|
||||||
|
entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `,
|
||||||
|
)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('demo-server-side', DemoServerSide);
|
||||||
|
export const serverSideCompletion = () => html`<demo-server-side></demo-server-side>`;
|
||||||
|
```
|
||||||
|
|
||||||
## Listbox compatibility
|
## Listbox compatibility
|
||||||
|
|
||||||
All configurations that can be applied to `lion-listbox`, can be applied to `lion-combobox` as well.
|
All configurations that can be applied to `lion-listbox`, can be applied to `lion-combobox` as well.
|
||||||
|
|
|
||||||
|
|
@ -431,12 +431,17 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Event} ev
|
* @configure ListboxMixin whenever the options are changed (potentially due to external causes
|
||||||
|
* like server side filtering of nodes), schedule autocompletion for proper highlighting
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
|
_onListboxContentChanged() {
|
||||||
|
super._onListboxContentChanged();
|
||||||
|
this.__shouldAutocompleteNextUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
_textboxOnInput(ev) {
|
_textboxOnInput() {
|
||||||
// Schedules autocompletion of options
|
|
||||||
this.__shouldAutocompleteNextUpdate = true;
|
this.__shouldAutocompleteNextUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -577,9 +582,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
_handleAutocompletion() {
|
_handleAutocompletion() {
|
||||||
// TODO: this is captured by 'noFilter'
|
|
||||||
// It should be removed and failing tests should be fixed. Currently, this line causes
|
|
||||||
// an empty box to keep showing its options when autocomplete is 'none'.
|
|
||||||
if (this.autocomplete === 'none') {
|
if (this.autocomplete === 'none') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1209,6 +1209,60 @@ describe('lion-combobox', () => {
|
||||||
expect(_inputNode.selectionEnd).to.equal('Ch'.length);
|
expect(_inputNode.selectionEnd).to.equal('Ch'.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Server side completion support', () => {
|
||||||
|
const listboxData = ['lorem', 'ipsum', 'dolor', 'sit', 'amet'];
|
||||||
|
|
||||||
|
class MyEl extends LitElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.options = [...listboxData];
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOptions() {
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.options = [];
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
addOption() {
|
||||||
|
this.options.push(`option ${this.options.length + 1}`);
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
get combobox() {
|
||||||
|
return /** @type {LionCombobox} */ (this.shadowRoot?.querySelector('#combobox'));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<lion-combobox id="combobox" label="Server side completion">
|
||||||
|
${this.options.map(
|
||||||
|
option => html` <lion-option .choiceValue="${option}">${option}</lion-option> `,
|
||||||
|
)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tagName = defineCE(MyEl);
|
||||||
|
const wrappingTag = unsafeStatic(tagName);
|
||||||
|
|
||||||
|
it('calls "_handleAutocompletion" after externally changing options', async () => {
|
||||||
|
const el = /** @type {MyEl} */ (await fixture(html`<${wrappingTag}></${wrappingTag}>`));
|
||||||
|
await el.combobox.registrationComplete;
|
||||||
|
// @ts-ignore [allow-protected] in test
|
||||||
|
const spy = sinon.spy(el.combobox, '_handleAutocompletion');
|
||||||
|
el.addOption();
|
||||||
|
await el.updateComplete;
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(spy).to.have.been.calledOnce;
|
||||||
|
el.clearOptions();
|
||||||
|
await el.updateComplete;
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(spy).to.have.been.calledTwice;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Subclassers', () => {
|
describe('Subclassers', () => {
|
||||||
it('allows to configure autoselect', async () => {
|
it('allows to configure autoselect', async () => {
|
||||||
class X extends LionCombobox {
|
class X extends LionCombobox {
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,12 @@ const ListboxMixinImplementation = superclass =>
|
||||||
/** @type {any[]} */
|
/** @type {any[]} */
|
||||||
this._initialModelValue = this.modelValue;
|
this._initialModelValue = this.modelValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every time new options are rendered from outside context, notify our parents
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
this._onListboxContentChanged();
|
||||||
|
});
|
||||||
|
observer.observe(this._listboxNode, { childList: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -511,7 +517,15 @@ const ListboxMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @protected */
|
/**
|
||||||
|
* A Subclasser can perform additional logic whenever the elements inside the listbox are
|
||||||
|
* updated. For instance, when a combobox does server side autocomplete, we want to
|
||||||
|
* match highlighted parts client side.
|
||||||
|
* @configurable
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
_onListboxContentChanged() {}
|
||||||
|
|
||||||
_teardownListboxNode() {
|
_teardownListboxNode() {
|
||||||
if (this._listboxNode) {
|
if (this._listboxNode) {
|
||||||
this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
|
this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { repeat, LitElement } from '@lion/core';
|
||||||
import { Required } from '@lion/form-core';
|
import { Required } from '@lion/form-core';
|
||||||
import { LionOptions } from '@lion/listbox';
|
import { LionOptions } from '@lion/listbox';
|
||||||
import '@lion/listbox/define';
|
import '@lion/listbox/define';
|
||||||
import { expect, fixture as _fixture, html, unsafeStatic } from '@open-wc/testing';
|
import { expect, fixture as _fixture, html, unsafeStatic, defineCE } from '@open-wc/testing';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { getListboxMembers } from '../test-helpers/index.js';
|
import { getListboxMembers } from '../test-helpers/index.js';
|
||||||
|
|
||||||
|
|
@ -1420,8 +1420,8 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const tagName = defineCE(MyEl);
|
||||||
customElements.define('my-el', MyEl);
|
const wrappingTag = unsafeStatic(tagName);
|
||||||
|
|
||||||
it('works with array map and repeat directive', async () => {
|
it('works with array map and repeat directive', async () => {
|
||||||
const choiceVals = (/** @type {LionListbox} */ elm) =>
|
const choiceVals = (/** @type {LionListbox} */ elm) =>
|
||||||
|
|
@ -1431,7 +1431,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
elm.formElements.filter(fel => elm._listboxNode.contains(fel)).length ===
|
elm.formElements.filter(fel => elm._listboxNode.contains(fel)).length ===
|
||||||
elm.formElements.length;
|
elm.formElements.length;
|
||||||
|
|
||||||
const el = /** @type {MyEl} */ (await _fixture(html`<my-el></my-el>`));
|
const el = /** @type {MyEl} */ (await _fixture(html`<${wrappingTag}></${wrappingTag}>`));
|
||||||
|
|
||||||
expect(choiceVals(el.withMap)).to.eql(el.options);
|
expect(choiceVals(el.withMap)).to.eql(el.options);
|
||||||
expect(el.withMap.formElements.length).to.equal(2);
|
expect(el.withMap.formElements.length).to.equal(2);
|
||||||
|
|
@ -1459,5 +1459,55 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
expect(insideListboxNode(el.withRepeat)).to.be.true;
|
expect(insideListboxNode(el.withRepeat)).to.be.true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Subclassers', () => {
|
||||||
|
class MyEl extends LitElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.options = ['option 1', 'option 2'];
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOptions() {
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.options = [];
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
addOption() {
|
||||||
|
this.options.push(`option ${this.options.length + 1}`);
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
get listbox() {
|
||||||
|
return /** @type {LionListbox} */ (this.shadowRoot?.querySelector('#listbox'));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<${tag} id="listbox">
|
||||||
|
${this.options.map(
|
||||||
|
option => html` <lion-option .choiceValue="${option}">${option}</lion-option> `,
|
||||||
|
)}
|
||||||
|
</${tag}>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tagName = defineCE(MyEl);
|
||||||
|
const wrappingTag = unsafeStatic(tagName);
|
||||||
|
|
||||||
|
it('calls "_onListboxContentChanged" after externally changing options', async () => {
|
||||||
|
const el = /** @type {MyEl} */ (await _fixture(html`<${wrappingTag}></${wrappingTag}>`));
|
||||||
|
await el.listbox.registrationComplete;
|
||||||
|
// @ts-ignore [allow-protected] in test
|
||||||
|
const spy = sinon.spy(el.listbox, '_onListboxContentChanged');
|
||||||
|
el.addOption();
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(spy).to.have.been.calledOnce;
|
||||||
|
el.clearOptions();
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(spy).to.have.been.calledTwice;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ export declare class ListboxHost {
|
||||||
protected _onChildActiveChanged(ev: Event): void;
|
protected _onChildActiveChanged(ev: Event): void;
|
||||||
|
|
||||||
protected get _activeDescendantOwnerNode(): HTMLElement;
|
protected get _activeDescendantOwnerNode(): HTMLElement;
|
||||||
|
|
||||||
|
protected _onListboxContentChanged(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare function ListboxImplementation<T extends Constructor<LitElement>>(
|
export declare function ListboxImplementation<T extends Constructor<LitElement>>(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue