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}>`)); + 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}">`; + } + return html`<${childTag} name="${field}">`; + })} + + `; + } + } + 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' }}" + > + + `)); + 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' }}" + > + + `)); + 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' }}"> + + `)); + 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' }}"> + + `)); + 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', + }}"> + + `)); + 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', + }}"> + + `)); + 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', + }}"> + + `)); + 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', + }}"> + + `)); + 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', + }}"> + + `)); + 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', + }}"> + + `)); + 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} .choiceValue=${'20'} checked id="second">Item 2 @@ -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} .choiceValue=${20}>Item 2 <${optionTag} .choiceValue=${30}>Item 3 @@ -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} .choiceValue=${20}>Item 2 <${optionTag} .choiceValue=${30}>Item 3 @@ -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} .choiceValue="${'Bla'}">Bla <${optionTag} .choiceValue="${'Chard'}">Chard @@ -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} .choiceValue="${'Bla'}">Bla <${optionTag} .choiceValue="${'Chard'}">Chard @@ -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} .choiceValue=${'Item 2'}>Item 2 <${optionTag} .choiceValue=${'Item 3'}>Item 3 @@ -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} .choiceValue="${'Chard'}">Chard @@ -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} .choiceValue="${'Chard'}">Chard @@ -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} .choiceValue=${20}>Item 2 <${optionTag} .choiceValue=${30}>Item 3 @@ -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} .choiceValue=${20}>Item 2 <${optionTag} .choiceValue=${30}>Item 3 @@ -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} id="withRepeat"> + ${repeat( + this.options, + option => option, + option => html` ${option} `, + )} + + `; + } + } + 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}>`)); + + 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} `, + )} + + `; + } + } + 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>( 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
+ + + `)); + 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;