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}>${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}>${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} `,
+ )}
+ ${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;
+ });
+ });
});
}
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>(