diff --git a/.changeset/calm-paws-taste.md b/.changeset/calm-paws-taste.md
new file mode 100644
index 000000000..399274f36
--- /dev/null
+++ b/.changeset/calm-paws-taste.md
@@ -0,0 +1,5 @@
+---
+'@lion/overlays': minor
+---
+
+expose "repositionOverlay()" on OverlayMixin
diff --git a/.changeset/calm-pots-tell.md b/.changeset/calm-pots-tell.md
new file mode 100644
index 000000000..c58f09052
--- /dev/null
+++ b/.changeset/calm-pots-tell.md
@@ -0,0 +1,5 @@
+---
+'@lion/listbox': patch
+---
+
+teleported options compatible with map/repeat for listbox/combobox/select-rich
diff --git a/.changeset/cold-papayas-judge.md b/.changeset/cold-papayas-judge.md
new file mode 100644
index 000000000..8c4d28f6c
--- /dev/null
+++ b/.changeset/cold-papayas-judge.md
@@ -0,0 +1,5 @@
+---
+'@lion/combobox': patch
+---
+
+do not reopen listbox on focusin edge cases
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/.changeset/lovely-tables-scream.md b/.changeset/lovely-tables-scream.md
new file mode 100644
index 000000000..322a73fa9
--- /dev/null
+++ b/.changeset/lovely-tables-scream.md
@@ -0,0 +1,5 @@
+---
+'@lion/form-core': patch
+---
+
+fixed css selector syntax for disabled [slot=input]'
diff --git a/.changeset/nasty-scissors-fix.md b/.changeset/nasty-scissors-fix.md
new file mode 100644
index 000000000..ff900d9d9
--- /dev/null
+++ b/.changeset/nasty-scissors-fix.md
@@ -0,0 +1,5 @@
+---
+'@lion/form-core': patch
+---
+
+form groups support lazily rendered children
diff --git a/.changeset/three-apples-appear.md b/.changeset/three-apples-appear.md
new file mode 100644
index 000000000..295db034b
--- /dev/null
+++ b/.changeset/three-apples-appear.md
@@ -0,0 +1,6 @@
+---
+'@lion/combobox': patch
+'@lion/listbox': patch
+---
+
+syncs last selected choice value for [autocomplet="none|list"] on close
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..9ecfdf41b 100644
--- a/packages/combobox/src/LionCombobox.js
+++ b/packages/combobox/src/LionCombobox.js
@@ -307,7 +307,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
}
/**
- * @param {'disabled'|'modelValue'|'readOnly'} name
+ * @param {'disabled'|'modelValue'|'readOnly'|'focused'} name
* @param {unknown} oldValue
*/
requestUpdateInternal(name, oldValue) {
@@ -324,6 +324,20 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
}
}
}
+ if (name === 'focused' && this.focused) {
+ this.__requestShowOverlay();
+ }
+ }
+
+ /**
+ * When textbox value doesn't match checkedIndex anymore, update accordingly...
+ * @protected
+ */
+ __unsyncCheckedIndexOnInputChange() {
+ const autoselect = this._autoSelectCondition();
+ if (!this.multipleChoice && !autoselect && !this._inputNode.value.startsWith(this.modelValue)) {
+ this.checkedIndex = -1;
+ }
}
/**
@@ -331,10 +345,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
*/
updated(changedProperties) {
super.updated(changedProperties);
- if (changedProperties.has('focused')) {
- if (this.focused) {
- this.__requestShowOverlay();
- }
+
+ if (changedProperties.has('__shouldAutocompleteNextUpdate')) {
+ // This check should take place before those below of 'opened' and
+ // '__shouldAutocompleteNextUpdate', to avoid race conditions
+ this.__unsyncCheckedIndexOnInputChange();
}
if (changedProperties.has('opened')) {
@@ -413,12 +428,18 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* return options.currentValue.length > 4 && super._showOverlayCondition(options);
* }
*
- * @param {{ currentValue: string, lastKey:string|undefined }} options
+ * @param {{ currentValue?: string, lastKey?: string }} options
* @protected
* @returns {boolean}
*/
+ // TODO: batch all pending condition triggers in __pendingShowTriggers, reducing race conditions
// eslint-disable-next-line class-methods-use-this
_showOverlayCondition({ lastKey }) {
+ const hideOn = ['Tab', 'Escape', 'Enter'];
+ if (lastKey && hideOn.includes(lastKey)) {
+ return false;
+ }
+
if (this.showAllOnEmpty && this.focused) {
return true;
}
@@ -426,17 +447,21 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
if (!lastKey) {
return /** @type {boolean} */ (this.opened);
}
- const doNotShowOn = ['Tab', 'Esc', 'Enter'];
- return !doNotShowOn.includes(lastKey);
+ return true;
}
/**
- * @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;
}
@@ -444,7 +469,10 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @param {KeyboardEvent} ev
* @protected
*/
+ // eslint-disable-next-line class-methods-use-this, no-unused-vars
_textboxOnKeydown(ev) {
+ // N.B. the check in _showOverlayCondition() is on keyup, and there is a subtle difference
+ // (see tests)
if (ev.key === 'Tab') {
this.opened = false;
}
@@ -456,11 +484,12 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
*/
_listboxOnClick(ev) {
super._listboxOnClick(ev);
+
+ this._inputNode.focus();
if (!this.multipleChoice) {
this.activeIndex = -1;
this.opened = false;
}
- this._inputNode.focus();
}
/**
@@ -577,13 +606,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;
- }
-
const hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart;
const curValue = this._inputNode.value;
@@ -604,7 +626,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
const isInlineAutoFillCandidate =
this.autocomplete === 'both' || this.autocomplete === 'inline';
const autoselect = this._autoSelectCondition();
- // @ts-ignore this.autocomplete === 'none' needs to be there if statement above is removed
const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none';
/** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */
@@ -754,6 +775,15 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
this.__setupCombobox();
}
+ /**
+ * @enhance OverlayMixin
+ * @protected
+ */
+ _teardownOverlayCtrl() {
+ super._teardownOverlayCtrl();
+ this.__teardownCombobox();
+ }
+
/**
* @enhance OverlayMixin
* @protected
@@ -889,8 +919,9 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @private
*/
__requestShowOverlay(ev) {
+ const lastKey = ev && ev.key;
this.opened = this._showOverlayCondition({
- lastKey: ev && ev.key,
+ lastKey,
currentValue: this._inputNode.value,
});
}
diff --git a/packages/combobox/test/lion-combobox.test.js b/packages/combobox/test/lion-combobox.test.js
index d80415cf4..563b6615e 100644
--- a/packages/combobox/test/lion-combobox.test.js
+++ b/packages/combobox/test/lion-combobox.test.js
@@ -46,6 +46,7 @@ function getComboboxMembers(el) {
* @param {LionCombobox} el
* @param {string} value
*/
+// TODO: add keys that actually make sense...
function mimicUserTyping(el, value) {
const { _inputNode } = getComboboxMembers(el);
_inputNode.dispatchEvent(new Event('focusin', { bubbles: true }));
@@ -57,7 +58,7 @@ function mimicUserTyping(el, value) {
}
/**
- * @param {HTMLInputElement} el
+ * @param {HTMLElement} el
* @param {string} key
*/
function mimicKeyPress(el, key) {
@@ -71,45 +72,44 @@ function mimicKeyPress(el, key) {
*/
async function mimicUserTypingAdvanced(el, values) {
const { _inputNode } = getComboboxMembers(el);
- const inputNodeLoc = /** @type {HTMLInputElement & {selectionStart:number, selectionEnd:number}} */ (_inputNode);
- inputNodeLoc.dispatchEvent(new Event('focusin', { bubbles: true }));
+ _inputNode.dispatchEvent(new Event('focusin', { bubbles: true }));
for (const key of values) {
// eslint-disable-next-line no-await-in-loop, no-loop-func
await new Promise(resolve => {
- const hasSelection = inputNodeLoc.selectionStart !== inputNodeLoc.selectionEnd;
+ const hasSelection = _inputNode.selectionStart !== _inputNode.selectionEnd;
if (key === 'Backspace') {
if (hasSelection) {
- inputNodeLoc.value =
- inputNodeLoc.value.slice(
+ _inputNode.value =
+ _inputNode.value.slice(
0,
- inputNodeLoc.selectionStart ? inputNodeLoc.selectionStart : undefined,
+ _inputNode.selectionStart ? _inputNode.selectionStart : undefined,
) +
- inputNodeLoc.value.slice(
- inputNodeLoc.selectionEnd ? inputNodeLoc.selectionEnd : undefined,
- inputNodeLoc.value.length,
+ _inputNode.value.slice(
+ _inputNode.selectionEnd ? _inputNode.selectionEnd : undefined,
+ _inputNode.value.length,
);
} else {
- inputNodeLoc.value = inputNodeLoc.value.slice(0, -1);
+ _inputNode.value = _inputNode.value.slice(0, -1);
}
} else if (hasSelection) {
- inputNodeLoc.value =
- inputNodeLoc.value.slice(
+ _inputNode.value =
+ _inputNode.value.slice(
0,
- inputNodeLoc.selectionStart ? inputNodeLoc.selectionStart : undefined,
+ _inputNode.selectionStart ? _inputNode.selectionStart : undefined,
) +
key +
- inputNodeLoc.value.slice(
- inputNodeLoc.selectionEnd ? inputNodeLoc.selectionEnd : undefined,
- inputNodeLoc.value.length,
+ _inputNode.value.slice(
+ _inputNode.selectionEnd ? _inputNode.selectionEnd : undefined,
+ _inputNode.value.length,
);
} else {
- inputNodeLoc.value += key;
+ _inputNode.value += key;
}
- mimicKeyPress(inputNodeLoc, key);
- inputNodeLoc.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
+ mimicKeyPress(_inputNode, key);
+ _inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
el.updateComplete.then(() => {
// @ts-ignore
@@ -157,41 +157,72 @@ async function fruitFixture({ autocomplete, matchMode } = {}) {
}
describe('lion-combobox', () => {
- describe('Options', () => {
- describe('showAllOnEmpty', () => {
- it('hides options when text in input node is cleared after typing something by default', async () => {
- const el = /** @type {LionCombobox} */ (await fixture(html`
-
- Artichoke
- Chard
- Chicory
- Victoria Plum
-
- `));
+ describe('Options visibility', () => {
+ it('hides options when text in input node is cleared after typing something by default', async () => {
+ const el = /** @type {LionCombobox} */ (await fixture(html`
+
+ Artichoke
+ Chard
+ Chicory
+ Victoria Plum
+
+ `));
- const options = el.formElements;
- const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
+ const options = el.formElements;
+ const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
- async function performChecks() {
- mimicUserTyping(el, 'c');
- await el.updateComplete;
- expect(visibleOptions().length).to.equal(4);
- mimicUserTyping(el, '');
- await el.updateComplete;
- expect(visibleOptions().length).to.equal(0);
- }
+ async function performChecks() {
+ mimicUserTyping(el, 'c');
+ await el.updateComplete;
+ expect(visibleOptions().length).to.equal(4);
+ mimicUserTyping(el, '');
+ await el.updateComplete;
+ expect(visibleOptions().length).to.equal(0);
+ }
- // FIXME: autocomplete 'none' should have this behavior as well
- // el.autocomplete = 'none';
- // await performChecks();
- el.autocomplete = 'list';
- await performChecks();
- el.autocomplete = 'inline';
- await performChecks();
- el.autocomplete = 'both';
- await performChecks();
- });
+ el.autocomplete = 'none';
+ await performChecks();
+ el.autocomplete = 'list';
+ await performChecks();
+ el.autocomplete = 'inline';
+ await performChecks();
+ el.autocomplete = 'both';
+ await performChecks();
+ });
+ it('hides listbox on click/enter (when multiple-choice is false)', async () => {
+ const el = /** @type {LionCombobox} */ (await fixture(html`
+
+ Artichoke
+ Chard
+ Chicory
+ Victoria Plum
+
+ `));
+
+ const { _comboboxNode, _listboxNode } = getComboboxMembers(el);
+
+ async function open() {
+ // activate opened listbox
+ _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
+ mimicUserTyping(el, 'ch');
+ return el.updateComplete;
+ }
+
+ await open();
+ expect(el.opened).to.be.true;
+ const visibleOptions = el.formElements.filter(o => o.style.display !== 'none');
+ visibleOptions[0].click();
+ expect(el.opened).to.be.false;
+ await open();
+ expect(el.opened).to.be.true;
+ el.activeIndex = el.formElements.indexOf(visibleOptions[0]);
+ mimicKeyPress(_listboxNode, 'Enter');
+ await el.updateComplete;
+ expect(el.opened).to.be.false;
+ });
+
+ describe('With ".showAllOnEmpty"', () => {
it('keeps showing options when text in input node is cleared after typing something', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html`
@@ -240,6 +271,71 @@ describe('lion-combobox', () => {
await el.updateComplete;
expect(el.opened).to.be.true;
});
+
+ it('hides overlay on focusin after [Escape]', async () => {
+ const el = /** @type {LionCombobox} */ (await fixture(html`
+
+ Artichoke
+ Chard
+ Chicory
+ Victoria Plum
+
+ `));
+ const { _comboboxNode, _inputNode } = getComboboxMembers(el);
+
+ expect(el.opened).to.be.false;
+ _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
+ await el.updateComplete;
+ expect(el.opened).to.be.true;
+ _inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
+ await el.updateComplete;
+ expect(el.opened).to.be.false;
+ });
+
+ it('hides listbox on click/enter (when multiple-choice is false)', async () => {
+ const el = /** @type {LionCombobox} */ (await fixture(html`
+
+ Artichoke
+ Chard
+ Chicory
+ Victoria Plum
+
+ `));
+
+ const { _comboboxNode, _listboxNode, _inputNode } = getComboboxMembers(el);
+
+ async function open() {
+ // activate opened listbox
+ _comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
+ mimicUserTyping(el, 'ch');
+ return el.updateComplete;
+ }
+
+ // FIXME: temp disable for Safari. Works locally, not in build
+ const isSafari = (() => {
+ const ua = navigator.userAgent.toLowerCase();
+ return ua.indexOf('safari') !== -1 && ua.indexOf('chrome') === -1;
+ })();
+ if (isSafari) {
+ return;
+ }
+
+ await open();
+ expect(el.opened).to.be.true;
+ const visibleOptions = el.formElements.filter(o => o.style.display !== 'none');
+ visibleOptions[0].click();
+ await el.updateComplete;
+ expect(el.opened).to.be.false;
+
+ _inputNode.value = '';
+ _inputNode.blur();
+ await open();
+ await el.updateComplete;
+
+ el.activeIndex = el.formElements.indexOf(visibleOptions[0]);
+ mimicKeyPress(_listboxNode, 'Enter');
+ expect(el.opened).to.be.false;
+ });
});
});
@@ -354,6 +450,38 @@ describe('lion-combobox', () => {
expect(el2.modelValue).to.eql([]);
expect(_inputNode.value).to.equal('');
});
+
+ it('syncs textbox to modelValue', async () => {
+ const el = /** @type {LionCombobox} */ (await fixture(html`
+
+ Aha
+ Bhb
+
+ `));
+ const { _inputNode } = getComboboxMembers(el);
+
+ async function performChecks() {
+ el.formElements[0].click();
+ await el.updateComplete;
+
+ // FIXME: fix properly for Webkit
+ // expect(_inputNode.value).to.equal('Aha');
+ expect(el.checkedIndex).to.equal(0);
+
+ mimicUserTyping(el, 'Ah');
+ await el.updateComplete;
+ expect(_inputNode.value).to.equal('Ah');
+
+ await el.updateComplete;
+ expect(el.checkedIndex).to.equal(-1);
+ }
+
+ el.autocomplete = 'none';
+ await performChecks();
+
+ el.autocomplete = 'list';
+ await performChecks();
+ });
});
describe('Overlay visibility', () => {
@@ -464,7 +592,9 @@ describe('lion-combobox', () => {
expect(el.opened).to.equal(true);
expect(_inputNode.value).to.equal('Artichoke');
- mimicKeyPress(_inputNode, 'Tab');
+ // N.B. we do only trigger keydown here (and not mimicKeypress (both keyup and down)),
+ // because this closely mimics what happens in the browser
+ _inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
expect(el.opened).to.equal(false);
expect(_inputNode.value).to.equal('Artichoke');
});
@@ -1209,6 +1339,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 {
@@ -1712,7 +1896,7 @@ describe('lion-combobox', () => {
describe('Multiple Choice', () => {
// TODO: possibly later share test with select-rich if it officially supports multipleChoice
- it('does not close listbox on click/enter/space', async () => {
+ it('does not close listbox on click/enter', async () => {
const el = /** @type {LionCombobox} */ (await fixture(html`
Artichoke
@@ -1733,10 +1917,8 @@ describe('lion-combobox', () => {
const visibleOptions = el.formElements.filter(o => o.style.display !== 'none');
visibleOptions[0].click();
expect(el.opened).to.equal(true);
- // visibleOptions[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
- // expect(el.opened).to.equal(true);
- // visibleOptions[2].dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
- // expect(el.opened).to.equal(true);
+ mimicKeyPress(visibleOptions[1], 'Enter');
+ expect(el.opened).to.equal(true);
});
});
diff --git a/packages/form-core/src/FormControlMixin.js b/packages/form-core/src/FormControlMixin.js
index 19feadbcc..a4638e765 100644
--- a/packages/form-core/src/FormControlMixin.js
+++ b/packages/form-core/src/FormControlMixin.js
@@ -684,7 +684,7 @@ const FormControlMixinImplementation = superclass =>
}
/***** {state} :disabled *****/
- :host([disabled]) .input-group ::slotted(slot='input') {
+ :host([disabled]) .input-group ::slotted([slot='input']) {
color: var(--disabled-text-color, #767676);
}
diff --git a/packages/form-core/src/form-group/FormGroupMixin.js b/packages/form-core/src/form-group/FormGroupMixin.js
index aecdf93d4..f97720c78 100644
--- a/packages/form-core/src/form-group/FormGroupMixin.js
+++ b/packages/form-core/src/form-group/FormGroupMixin.js
@@ -148,6 +148,9 @@ const FormGroupMixinImplementation = superclass =>
this.defaultValidators = [new FormElementsHaveNoError()];
this.__descriptionElementsInParentChain = new Set();
+
+ /** @type {{modelValue?:{[key:string]: any}, serializedValue?:{[key:string]: any}}} */
+ this.__pendingValues = { modelValue: {}, serializedValue: {} };
}
connectedCallback() {
@@ -349,6 +352,8 @@ const FormGroupMixinImplementation = superclass =>
}
if (this.formElements[name]) {
this.formElements[name][property] = values[name];
+ } else {
+ this.__pendingValues[property][name] = values[name];
}
});
}
@@ -485,7 +490,7 @@ const FormGroupMixinImplementation = superclass =>
* @override of FormRegistrarMixin.
* @desc Connects ValidateMixin and DisabledMixin
* On top of this, error messages of children are linked to their parents
- * @param {FormControl} child
+ * @param {FormControl & {serializedValue:string|object}} child
* @param {number} indexToInsertAt
*/
addFormElement(child, indexToInsertAt) {
@@ -502,6 +507,16 @@ const FormGroupMixinImplementation = superclass =>
if (typeof child.addToAriaLabelledBy === 'function' && this._labelNode) {
child.addToAriaLabelledBy(this._labelNode, { reorder: false });
}
+ if (!child.modelValue) {
+ const pVals = this.__pendingValues;
+ if (pVals.modelValue && pVals.modelValue[child.name]) {
+ // eslint-disable-next-line no-param-reassign
+ child.modelValue = pVals.modelValue[child.name];
+ } else if (pVals.serializedValue && pVals.serializedValue[child.name]) {
+ // eslint-disable-next-line no-param-reassign
+ child.serializedValue = pVals.serializedValue[child.name];
+ }
+ }
}
/**
diff --git a/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js b/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js
index acc0d9fef..9e9929633 100644
--- a/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js
+++ b/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js
@@ -1,4 +1,4 @@
-import { LitElement } from '@lion/core';
+import { LitElement, ifDefined } from '@lion/core';
import { localizeTearDown } from '@lion/localize/test-helpers';
import {
defineCE,
@@ -1165,5 +1165,224 @@ export function runFormGroupMixinSuite(cfg = {}) {
expect(el.getAttribute('aria-labelledby')).contains(label.id);
});
});
+
+ describe('Dynamically rendered children', () => {
+ class DynamicCWrapper extends LitElement {
+ static get properties() {
+ return {
+ fields: { type: Array },
+ };
+ }
+
+ constructor() {
+ super();
+ /** @type {any[]} */
+ this.fields = [];
+ /** @type {object|undefined} */
+ this.modelValue = undefined;
+ /** @type {object|undefined} */
+ this.serializedValue = undefined;
+ }
+
+ render() {
+ return html`
+ <${tag}
+ .modelValue=${ifDefined(this.modelValue)}
+ .serializedValue=${ifDefined(this.serializedValue)}>
+ ${this.fields.map(field => {
+ if (typeof field === 'object') {
+ return html`<${childTag} name="${field.name}" .modelValue="${field.value}">${childTag}>`;
+ }
+ return html`<${childTag} name="${field}">${childTag}>`;
+ })}
+ ${tag}>
+ `;
+ }
+ }
+ const dynamicChildrenTagString = defineCE(DynamicCWrapper);
+ const dynamicChildrenTag = unsafeStatic(dynamicChildrenTagString);
+
+ it(`when rendering children right from the start, sets their values correctly
+ based on prefilled model/seriazedValue`, async () => {
+ const el = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag}
+ .fields="${['firstName', 'lastName']}"
+ .modelValue="${{ firstName: 'foo', lastName: 'bar' }}"
+ >
+ ${dynamicChildrenTag}>
+ `));
+ await el.updateComplete;
+ const fieldset = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el.shadowRoot).querySelector(tagString)
+ );
+ expect(fieldset.formElements[0].modelValue).to.equal('foo');
+ expect(fieldset.formElements[1].modelValue).to.equal('bar');
+
+ const el2 = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag}
+ .fields="${['firstName', 'lastName']}"
+ .serializedValue="${{ firstName: 'foo', lastName: 'bar' }}"
+ >
+ ${dynamicChildrenTag}>
+ `));
+ await el2.updateComplete;
+ const fieldset2 = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el2.shadowRoot).querySelector(tagString)
+ );
+ expect(fieldset2.formElements[0].serializedValue).to.equal('foo');
+ expect(fieldset2.formElements[1].serializedValue).to.equal('bar');
+ });
+
+ it(`when rendering children delayed, sets their values
+ correctly based on prefilled model/seriazedValue`, async () => {
+ const el = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag} .modelValue="${{ firstName: 'foo', lastName: 'bar' }}">
+ ${dynamicChildrenTag}>
+ `));
+ await el.updateComplete;
+ const fieldset = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el.shadowRoot).querySelector(tagString)
+ );
+ el.fields = ['firstName', 'lastName'];
+ await el.updateComplete;
+ expect(fieldset.formElements[0].modelValue).to.equal('foo');
+ expect(fieldset.formElements[1].modelValue).to.equal('bar');
+
+ const el2 = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag} .serializedValue="${{ firstName: 'foo', lastName: 'bar' }}">
+ ${dynamicChildrenTag}>
+ `));
+ await el2.updateComplete;
+ const fieldset2 = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el2.shadowRoot).querySelector(tagString)
+ );
+ el2.fields = ['firstName', 'lastName'];
+ await el2.updateComplete;
+ expect(fieldset2.formElements[0].serializedValue).to.equal('foo');
+ expect(fieldset2.formElements[1].serializedValue).to.equal('bar');
+ });
+
+ it(`when rendering children partly delayed, sets their values correctly based on
+ prefilled model/seriazedValue`, async () => {
+ const el = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag} .fields="${['firstName']}" .modelValue="${{
+ firstName: 'foo',
+ lastName: 'bar',
+ }}">
+ ${dynamicChildrenTag}>
+ `));
+ await el.updateComplete;
+ const fieldset = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el.shadowRoot).querySelector(tagString)
+ );
+ el.fields = ['firstName', 'lastName'];
+ await el.updateComplete;
+ expect(fieldset.formElements[0].modelValue).to.equal('foo');
+ expect(fieldset.formElements[1].modelValue).to.equal('bar');
+
+ const el2 = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag} .fields="${['firstName']}" .serializedValue="${{
+ firstName: 'foo',
+ lastName: 'bar',
+ }}">
+ ${dynamicChildrenTag}>
+ `));
+ await el2.updateComplete;
+ const fieldset2 = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el2.shadowRoot).querySelector(tagString)
+ );
+ el2.fields = ['firstName', 'lastName'];
+ await el2.updateComplete;
+ expect(fieldset2.formElements[0].serializedValue).to.equal('foo');
+ expect(fieldset2.formElements[1].serializedValue).to.equal('bar');
+ });
+
+ it(`does not change interaction states when values set for delayed children`, async () => {
+ function expectInteractionStatesToBeCorrectFor(/** @type {FormChild|FormGroup} */ elm) {
+ expect(Boolean(elm.submitted)).to.be.false;
+ expect(elm.dirty).to.be.false;
+ expect(elm.touched).to.be.false;
+ expect(elm.prefilled).to.be.true;
+ }
+
+ const el = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag} .fields="${['firstName']}" .modelValue="${{
+ firstName: 'foo',
+ lastName: 'bar',
+ }}">
+ ${dynamicChildrenTag}>
+ `));
+ await el.updateComplete;
+ const fieldset = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el.shadowRoot).querySelector(tagString)
+ );
+ await fieldset.registrationComplete;
+
+ el.fields = ['firstName', 'lastName'];
+ await el.updateComplete;
+ expectInteractionStatesToBeCorrectFor(fieldset.formElements[0]);
+ expectInteractionStatesToBeCorrectFor(fieldset.formElements[1]);
+ expectInteractionStatesToBeCorrectFor(fieldset);
+
+ const el2 = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag} .fields="${['firstName']}" .serializedValue="${{
+ firstName: 'foo',
+ lastName: 'bar',
+ }}">
+ ${dynamicChildrenTag}>
+ `));
+ await el2.updateComplete;
+ const fieldset2 = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el2.shadowRoot).querySelector(tagString)
+ );
+ await fieldset2.registrationComplete;
+
+ el2.fields = ['firstName', 'lastName'];
+ await el2.updateComplete;
+ expectInteractionStatesToBeCorrectFor(fieldset2.formElements[0]);
+ expectInteractionStatesToBeCorrectFor(fieldset2.formElements[1]);
+ expectInteractionStatesToBeCorrectFor(fieldset2);
+ });
+
+ it(`prefilled children values take precedence over parent values`, async () => {
+ const el = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag} .modelValue="${{
+ firstName: 'foo',
+ lastName: 'bar',
+ }}">
+ ${dynamicChildrenTag}>
+ `));
+ await el.updateComplete;
+ const fieldset = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el.shadowRoot).querySelector(tagString)
+ );
+ el.fields = [
+ { name: 'firstName', value: 'wins' },
+ { name: 'lastName', value: 'winsAsWell' },
+ ];
+ await el.updateComplete;
+ expect(fieldset.formElements[0].modelValue).to.equal('wins');
+ expect(fieldset.formElements[1].modelValue).to.equal('winsAsWell');
+
+ const el2 = /** @type {DynamicCWrapper} */ (await fixture(html`
+ <${dynamicChildrenTag} .serializedValue="${{
+ firstName: 'foo',
+ lastName: 'bar',
+ }}">
+ ${dynamicChildrenTag}>
+ `));
+ await el2.updateComplete;
+ const fieldset2 = /** @type {FormGroup} */ (
+ /** @type {ShadowRoot} */ (el2.shadowRoot).querySelector(tagString)
+ );
+ el2.fields = [
+ { name: 'firstName', value: 'wins' },
+ { name: 'lastName', value: 'winsAsWell' },
+ ];
+ await el2.updateComplete;
+ expect(fieldset2.formElements[0].serializedValue).to.equal('wins');
+ expect(fieldset2.formElements[1].serializedValue).to.equal('winsAsWell');
+ });
+ });
});
}
diff --git a/packages/form-integrations/test/model-value-consistency.test.js b/packages/form-integrations/test/model-value-consistency.test.js
index 07f7291b8..db413cafa 100644
--- a/packages/form-integrations/test/model-value-consistency.test.js
+++ b/packages/form-integrations/test/model-value-consistency.test.js
@@ -509,6 +509,7 @@ describe('detail.isTriggeredByUser', () => {
if (type === 'OptionChoiceField' && testKeyboardBehavior) {
resetChoiceFieldToForceRepropagation(formControl);
mimicUserInput(formControl, 'userValue', 'keypress');
+ // TODO: get rid of try/catch (?)...
try {
expect(spy.firstCall.args[0].detail.isTriggeredByUser).to.be.true;
} catch (e) {
diff --git a/packages/listbox/src/ListboxMixin.js b/packages/listbox/src/ListboxMixin.js
index aa2296a15..6cbb1acb5 100644
--- a/packages/listbox/src/ListboxMixin.js
+++ b/packages/listbox/src/ListboxMixin.js
@@ -18,6 +18,41 @@ import { LionOptions } from './LionOptions.js';
* @typedef {import('@lion/form-core/types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
*/
+// TODO: consider adding methods below to @lion/helpers
+
+/**
+ * Sometimes, we want to provide best DX (direct slottables) and be accessible
+ * at the same time.
+ * In the first example below, we need to wrap our options in light dom in an element with
+ * [role=listbox]. We could achieve this via the second example, but it would affect our
+ * public api negatively. not allowing us to be forward compatible with the AOM spec:
+ * https://wicg.github.io/aom/explainer.html
+ * With this method, it's possible to watch elements in the default slot and move them
+ * to the desired target (the element with [role=listbox]) in light dom.
+ *
+ * @example
+ * # desired api
+ *
+ *
+ *
+ * # desired end state
+ *
+ *
+ *
+ *
+ *
+ * @param {HTMLElement} source host of ShadowRoot with default
+ * @param {HTMLElement} target the desired target in light dom
+ */
+function moveDefaultSlottablesToTarget(source, target) {
+ Array.from(source.childNodes).forEach((/** @type {* & Element} */ c) => {
+ const isNamedSlottable = c.hasAttribute && c.hasAttribute('slot');
+ if (!isNamedSlottable) {
+ target.appendChild(c);
+ }
+ });
+}
+
function uuid() {
return Math.random().toString(36).substr(2, 10);
}
@@ -344,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 });
}
/**
@@ -476,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);
@@ -821,13 +870,9 @@ const ListboxMixinImplementation = superclass =>
);
if (slot) {
- slot.assignedNodes().forEach(node => {
- this._listboxNode.appendChild(node);
- });
+ moveDefaultSlottablesToTarget(this, this._listboxNode);
slot.addEventListener('slotchange', () => {
- slot.assignedNodes().forEach(node => {
- this._listboxNode.appendChild(node);
- });
+ moveDefaultSlottablesToTarget(this, this._listboxNode);
});
}
}
diff --git a/packages/listbox/test-suites/ListboxMixin.suite.js b/packages/listbox/test-suites/ListboxMixin.suite.js
index 36e908d86..dd75adfa0 100644
--- a/packages/listbox/test-suites/ListboxMixin.suite.js
+++ b/packages/listbox/test-suites/ListboxMixin.suite.js
@@ -1,8 +1,9 @@
import '@lion/core/differentKeyEventNamesShimIE';
+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';
@@ -24,23 +25,6 @@ function mimicKeyPress(el, key) {
el.dispatchEvent(new KeyboardEvent('keyup', { key }));
}
-// /**
-// * @param {LionListbox} lionListboxEl
-// */
-// function getProtectedMembers(lionListboxEl) {
-// // @ts-ignore protected members allowed in test
-// const {
-// _inputNode: input,
-// _activeDescendantOwnerNode: activeDescendantOwner,
-// _listboxNode: listbox,
-// } = lionListboxEl;
-// return {
-// input,
-// activeDescendantOwner,
-// listbox,
-// };
-// }
-
/**
* @param { {tagString?:string, optionTagString?:string} } [customConfig]
*/
@@ -381,7 +365,7 @@ export function runListboxMixinSuite(customConfig = {}) {
it('has a reference to the active option', async () => {
const el = await fixture(html`
- <${tag} opened has-no-default-selected autocomplete="none">
+ <${tag} opened has-no-default-selected autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue=${'10'} id="first">Item 1${optionTag}>
<${optionTag} .choiceValue=${'20'} checked id="second">Item 2${optionTag}>
${tag}>
@@ -403,7 +387,7 @@ export function runListboxMixinSuite(customConfig = {}) {
it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
const el = /** @type {LionListbox} */ (await fixture(html`
- <${tag} autocomplete="none">
+ <${tag} autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue=${10}>Item 1${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3${optionTag}>
@@ -416,7 +400,7 @@ export function runListboxMixinSuite(customConfig = {}) {
it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => {
const el = await fixture(html`
- <${tag} autocomplete="none">
+ <${tag} autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue=${10}>Item 1${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3${optionTag}>
@@ -604,7 +588,7 @@ export function runListboxMixinSuite(customConfig = {}) {
describe('Enter', () => {
it('[Enter] selects active option', async () => {
const el = /** @type {LionListbox} */ (await fixture(html`
- <${tag} opened name="foo" autocomplete="none">
+ <${tag} opened name="foo" autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke${optionTag}>
<${optionTag} .choiceValue="${'Bla'}">Bla${optionTag}>
<${optionTag} .choiceValue="${'Chard'}">Chard${optionTag}>
@@ -627,7 +611,7 @@ export function runListboxMixinSuite(customConfig = {}) {
// When listbox is not focusable (in case of a combobox), the user should be allowed
// to enter a space in the focusable element (texbox)
const el = /** @type {LionListbox} */ (await fixture(html`
- <${tag} opened name="foo" ._listboxReceivesNoFocus="${false}" autocomplete="none">
+ <${tag} opened name="foo" ._listboxReceivesNoFocus="${false}" autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke${optionTag}>
<${optionTag} .choiceValue="${'Bla'}">Bla${optionTag}>
<${optionTag} .choiceValue="${'Chard'}">Chard${optionTag}>
@@ -703,7 +687,7 @@ export function runListboxMixinSuite(customConfig = {}) {
});
it('navigates through open lists with [ArrowDown] [ArrowUp] keys activates the option', async () => {
const el = /** @type {LionListbox} */ (await fixture(html`
- <${tag} opened has-no-default-selected autocomplete="none">
+ <${tag} opened has-no-default-selected autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue=${'Item 1'}>Item 1${optionTag}>
<${optionTag} .choiceValue=${'Item 2'}>Item 2${optionTag}>
<${optionTag} .choiceValue=${'Item 3'}>Item 3${optionTag}>
@@ -731,7 +715,7 @@ export function runListboxMixinSuite(customConfig = {}) {
describe('Orientation', () => {
it('has a default value of "vertical"', async () => {
const el = /** @type {LionListbox} */ (await fixture(html`
- <${tag} opened name="foo" autocomplete="none">
+ <${tag} opened name="foo" autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke${optionTag}>
<${optionTag} .choiceValue="${'Chard'}">Chard${optionTag}>
${tag}>
@@ -771,7 +755,7 @@ export function runListboxMixinSuite(customConfig = {}) {
it('uses [ArrowLeft] and [ArrowRight] keys when "horizontal"', async () => {
const el = /** @type {LionListbox} */ (await fixture(html`
- <${tag} opened name="foo" orientation="horizontal" autocomplete="none">
+ <${tag} opened name="foo" orientation="horizontal" autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke${optionTag}>
<${optionTag} .choiceValue="${'Chard'}">Chard${optionTag}>
${tag}>
@@ -948,7 +932,7 @@ export function runListboxMixinSuite(customConfig = {}) {
});
}
const el = /** @type {LionListbox} */ (await fixture(html`
- <${tag} opened selection-follows-focus autocomplete="none">
+ <${tag} opened selection-follows-focus autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue=${10}>Item 1${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3${optionTag}>
@@ -988,7 +972,7 @@ export function runListboxMixinSuite(customConfig = {}) {
});
}
const el = /** @type {LionListbox} */ (await fixture(html`
- <${tag} opened selection-follows-focus orientation="horizontal" autocomplete="none">
+ <${tag} opened selection-follows-focus orientation="horizontal" autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue=${10}>Item 1${optionTag}>
<${optionTag} .choiceValue=${20}>Item 2${optionTag}>
<${optionTag} .choiceValue=${30}>Item 3${optionTag}>
@@ -1384,5 +1368,146 @@ export function runListboxMixinSuite(customConfig = {}) {
expect(clickSpy).to.not.have.been.called;
});
});
+
+ describe('Dynamically adding options', () => {
+ 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 withMap() {
+ return /** @type {LionListbox} */ (this.shadowRoot?.querySelector('#withMap'));
+ }
+
+ get withRepeat() {
+ return /** @type {LionListbox} */ (this.shadowRoot?.querySelector('#withRepeat'));
+ }
+
+ get registrationComplete() {
+ return Promise.all([
+ this.withMap.registrationComplete,
+ this.withRepeat.registrationComplete,
+ ]);
+ }
+
+ render() {
+ return html`
+ <${tag} id="withMap">
+ ${this.options.map(
+ option => html` ${option} `,
+ )}
+ ${tag}>
+ <${tag} id="withRepeat">
+ ${repeat(
+ this.options,
+ option => option,
+ option => html` ${option} `,
+ )}
+ ${tag}>
+ `;
+ }
+ }
+ const tagName = defineCE(MyEl);
+ const wrappingTag = unsafeStatic(tagName);
+
+ it('works with array map and repeat directive', async () => {
+ const choiceVals = (/** @type {LionListbox} */ elm) =>
+ elm.formElements.map(fel => fel.choiceValue);
+ const insideListboxNode = (/** @type {LionListbox} */ elm) =>
+ // @ts-ignore [allow-protected] in test
+ elm.formElements.filter(fel => elm._listboxNode.contains(fel)).length ===
+ elm.formElements.length;
+
+ 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);
+ expect(insideListboxNode(el.withMap)).to.be.true;
+ expect(choiceVals(el.withRepeat)).to.eql(el.options);
+ expect(el.withRepeat.formElements.length).to.equal(2);
+ expect(insideListboxNode(el.withRepeat)).to.be.true;
+
+ el.addOption();
+ await el.updateComplete;
+ expect(choiceVals(el.withMap)).to.eql(el.options);
+ expect(el.withMap.formElements.length).to.equal(3);
+ expect(insideListboxNode(el.withMap)).to.be.true;
+ expect(choiceVals(el.withRepeat)).to.eql(el.options);
+ expect(el.withRepeat.formElements.length).to.equal(3);
+ expect(insideListboxNode(el.withRepeat)).to.be.true;
+
+ el.clearOptions();
+ await el.updateComplete;
+ expect(choiceVals(el.withMap)).to.eql(el.options);
+ expect(el.withMap.formElements.length).to.equal(0);
+ expect(insideListboxNode(el.withMap)).to.be.true;
+ expect(choiceVals(el.withRepeat)).to.eql(el.options);
+ expect(el.withRepeat.formElements.length).to.equal(0);
+ 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>(
diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js
index bddab5239..c19f0e5e2 100644
--- a/packages/overlays/src/OverlayController.js
+++ b/packages/overlays/src/OverlayController.js
@@ -471,8 +471,6 @@ export class OverlayController extends EventTargetShim {
/** @private */
this.__validateConfiguration(/** @type {OverlayConfig} */ (this.config));
- // TODO: remove this, so we only have the getters (no setters)
- // Object.assign(this, this.config);
/** @protected */
this._init({ cfgToAdd });
/** @private */
diff --git a/packages/overlays/src/OverlayMixin.js b/packages/overlays/src/OverlayMixin.js
index cd97790f0..8eed5bf4f 100644
--- a/packages/overlays/src/OverlayMixin.js
+++ b/packages/overlays/src/OverlayMixin.js
@@ -79,7 +79,6 @@ export const OverlayMixinImplementation = superclass =>
// eslint-disable-next-line
_defineOverlay({ contentNode, invokerNode, referenceNode, backdropNode, contentWrapperNode }) {
const overlayConfig = this._defineOverlayConfig() || {};
-
return new OverlayController({
contentNode,
invokerNode,
@@ -353,5 +352,17 @@ export const OverlayMixinImplementation = superclass =>
async close() {
await /** @type {OverlayController} */ (this._overlayCtrl).hide();
}
+
+ /**
+ * Sometimes it's needed to recompute Popper position of an overlay, for instance when we have
+ * an opened combobox and the surrounding context changes (the space consumed by the textbox
+ * increases vertically)
+ */
+ repositionOverlay() {
+ const ctrl = /** @type {OverlayController} */ (this._overlayCtrl);
+ if (ctrl.placementMode === 'local' && ctrl._popper) {
+ ctrl._popper.update();
+ }
+ }
};
export const OverlayMixin = dedupeMixin(OverlayMixinImplementation);
diff --git a/packages/overlays/test-suites/OverlayMixin.suite.js b/packages/overlays/test-suites/OverlayMixin.suite.js
index 02c25c681..6da75cec7 100644
--- a/packages/overlays/test-suites/OverlayMixin.suite.js
+++ b/packages/overlays/test-suites/OverlayMixin.suite.js
@@ -1,7 +1,6 @@
import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing';
import sinon from 'sinon';
import { overlays } from '../src/overlays.js';
-// eslint-disable-next-line no-unused-vars
import { OverlayController } from '../src/OverlayController.js';
/**
@@ -171,7 +170,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
expect(el.opened).to.be.true;
});
- it('allows to call `preventDefault()` on "before-opened"/"before-closed" events', async () => {
+ it('allows to call "preventDefault()" on "before-opened"/"before-closed" events', async () => {
function preventer(/** @type Event */ ev) {
ev.preventDefault();
}
@@ -215,7 +214,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
});
// See https://github.com/ing-bank/lion/discussions/1095
- it('exposes open(), close() and toggle() methods', async () => {
+ it('exposes "open()", "close()" and "toggle()" methods', async () => {
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}>
content
@@ -240,6 +239,25 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
expect(el.opened).to.be.false;
});
+ it('exposes "repositionOverlay()" method', async () => {
+ const el = /** @type {OverlayEl} */ (await fixture(html`
+ <${tag} opened .config="${{ placementMode: 'local' }}">
+ content
+
+ ${tag}>
+ `));
+ await OverlayController.popperModule;
+ sinon.spy(el._overlayCtrl._popper, 'update');
+ el.repositionOverlay();
+ expect(el._overlayCtrl._popper.update).to.have.been.been.calledOnce;
+
+ if (!el._overlayCtrl.isTooltip) {
+ el.config = { ...el.config, placementMode: 'global' };
+ el.repositionOverlay();
+ expect(el._overlayCtrl._popper.update).to.have.been.been.calledOnce;
+ }
+ });
+
/** See: https://github.com/ing-bank/lion/issues/1075 */
it('stays open after config update', async () => {
const el = /** @type {OverlayEl} */ (await fixture(html`
@@ -250,6 +268,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
`));
el.open();
await el._overlayCtrl._showComplete;
+
el.config = { ...el.config, hidesOnOutsideClick: !el.config.hidesOnOutsideClick };
await nextFrame();
expect(el.opened).to.be.true;
diff --git a/packages/overlays/types/OverlayMixinTypes.d.ts b/packages/overlays/types/OverlayMixinTypes.d.ts
index d1c7e90f3..58ac0c2ff 100644
--- a/packages/overlays/types/OverlayMixinTypes.d.ts
+++ b/packages/overlays/types/OverlayMixinTypes.d.ts
@@ -17,14 +17,19 @@ export interface DefineOverlayConfig {
}
export declare class OverlayHost {
- public opened: Boolean;
+ opened: Boolean;
+ get config(): OverlayConfig;
+ set config(value: OverlayConfig);
- public get config(): OverlayConfig;
- public set config(value: OverlayConfig);
-
- public open(): void;
- public close(): void;
- public toggle(): void;
+ open(): void;
+ close(): void;
+ toggle(): void;
+ /**
+ * Sometimes it's needed to recompute Popper position of an overlay, for instance when we have
+ * an opened combobox and the surrounding context changes (the space consumed by the textbox
+ * increases vertically)
+ */
+ repositionOverlay(): void;
protected _overlayCtrl: OverlayController;