diff --git a/.changeset/long-foxes-smash.md b/.changeset/long-foxes-smash.md new file mode 100644 index 000000000..4110c10db --- /dev/null +++ b/.changeset/long-foxes-smash.md @@ -0,0 +1,5 @@ +--- +'@lion/combobox': minor +--- + +automatically recompute autocompletion features when options change (needed for server side completion support) diff --git a/docs/components/inputs/combobox/features.md b/docs/components/inputs/combobox/features.md index 5a8cdd9a5..2763dd637 100644 --- a/docs/components/inputs/combobox/features.md +++ b/docs/components/inputs/combobox/features.md @@ -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) ```js script -import { html } from '@lion/core'; +import { LitElement, html, repeat } from '@lion/core'; import { listboxData } from '../../../../packages/listbox/docs/listboxData.js'; import '@lion/listbox/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` + + + ${repeat( + this.options, + entry => entry, + entry => html` ${entry} `, + )} + + `; + } +} +customElements.define('demo-server-side', DemoServerSide); +export const serverSideCompletion = () => html``; +``` + ## Listbox compatibility All configurations that can be applied to `lion-listbox`, can be applied to `lion-combobox` as well. diff --git a/packages/combobox/src/LionCombobox.js b/packages/combobox/src/LionCombobox.js index d9faa5d9c..545edc52a 100644 --- a/packages/combobox/src/LionCombobox.js +++ b/packages/combobox/src/LionCombobox.js @@ -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 */ + _onListboxContentChanged() { + super._onListboxContentChanged(); + this.__shouldAutocompleteNextUpdate = true; + } + // eslint-disable-next-line no-unused-vars - _textboxOnInput(ev) { - // Schedules autocompletion of options + _textboxOnInput() { this.__shouldAutocompleteNextUpdate = true; } @@ -577,9 +582,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) { * @protected */ _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') { return; } diff --git a/packages/combobox/test/lion-combobox.test.js b/packages/combobox/test/lion-combobox.test.js index d80415cf4..c909a837a 100644 --- a/packages/combobox/test/lion-combobox.test.js +++ b/packages/combobox/test/lion-combobox.test.js @@ -1209,6 +1209,60 @@ describe('lion-combobox', () => { 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` + + ${this.options.map( + option => html` ${option} `, + )} + + `; + } + } + const tagName = defineCE(MyEl); + const wrappingTag = unsafeStatic(tagName); + + it('calls "_handleAutocompletion" after externally changing options', async () => { + const el = /** @type {MyEl} */ (await fixture(html`<${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', () => { it('allows to configure autoselect', async () => { class X extends LionCombobox { diff --git a/packages/listbox/src/ListboxMixin.js b/packages/listbox/src/ListboxMixin.js index 38a9974ca..6cbb1acb5 100644 --- a/packages/listbox/src/ListboxMixin.js +++ b/packages/listbox/src/ListboxMixin.js @@ -379,6 +379,12 @@ const ListboxMixinImplementation = superclass => /** @type {any[]} */ 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() { if (this._listboxNode) { this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown); diff --git a/packages/listbox/test-suites/ListboxMixin.suite.js b/packages/listbox/test-suites/ListboxMixin.suite.js index 72ec2de52..05a983d6b 100644 --- a/packages/listbox/test-suites/ListboxMixin.suite.js +++ b/packages/listbox/test-suites/ListboxMixin.suite.js @@ -3,7 +3,7 @@ import { repeat, LitElement } from '@lion/core'; import { Required } from '@lion/form-core'; import { LionOptions } from '@lion/listbox'; 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 { getListboxMembers } from '../test-helpers/index.js'; @@ -1420,8 +1420,8 @@ export function runListboxMixinSuite(customConfig = {}) { `; } } - - customElements.define('my-el', MyEl); + const tagName = defineCE(MyEl); + const wrappingTag = unsafeStatic(tagName); it('works with array map and repeat directive', async () => { const choiceVals = (/** @type {LionListbox} */ elm) => @@ -1431,7 +1431,7 @@ export function runListboxMixinSuite(customConfig = {}) { elm.formElements.filter(fel => elm._listboxNode.contains(fel)).length === elm.formElements.length; - const el = /** @type {MyEl} */ (await _fixture(html``)); + const el = /** @type {MyEl} */ (await _fixture(html`<${wrappingTag}>`)); expect(choiceVals(el.withMap)).to.eql(el.options); expect(el.withMap.formElements.length).to.equal(2); @@ -1459,5 +1459,55 @@ export function runListboxMixinSuite(customConfig = {}) { 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` ${option} `, + )} + + `; + } + } + const tagName = defineCE(MyEl); + const wrappingTag = unsafeStatic(tagName); + + it('calls "_onListboxContentChanged" after externally changing options', async () => { + const el = /** @type {MyEl} */ (await _fixture(html`<${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; + }); + }); }); } diff --git a/packages/listbox/types/ListboxMixinTypes.d.ts b/packages/listbox/types/ListboxMixinTypes.d.ts index 02c10b57e..568135daa 100644 --- a/packages/listbox/types/ListboxMixinTypes.d.ts +++ b/packages/listbox/types/ListboxMixinTypes.d.ts @@ -74,6 +74,8 @@ export declare class ListboxHost { protected _onChildActiveChanged(ev: Event): void; protected get _activeDescendantOwnerNode(): HTMLElement; + + protected _onListboxContentChanged(): void; } export declare function ListboxImplementation>(