feat(listbox): _onListboxContenChanged hook server side autocompletion

Co-authored-by: palash2601 <palash2601@gmail.com>
This commit is contained in:
Thijs Louisse 2020-10-05 13:47:31 +02:00
parent d94d6bd84a
commit 6aa7fc29c8
7 changed files with 215 additions and 12 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/combobox': minor
---
automatically recompute autocompletion features when options change (needed for server side completion support)

View file

@ -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.

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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>>(