Merge pull request #1341 from ing-bank/fix/listboxDynamicOptions

Super Mega Fix and Feature Pack™
This commit is contained in:
Thijs Louisse 2021-04-20 13:49:36 +02:00 committed by GitHub
commit 4450199407
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 895 additions and 130 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/overlays': minor
---
expose "repositionOverlay()" on OverlayMixin

View file

@ -0,0 +1,5 @@
---
'@lion/listbox': patch
---
teleported options compatible with map/repeat for listbox/combobox/select-rich

View file

@ -0,0 +1,5 @@
---
'@lion/combobox': patch
---
do not reopen listbox on focusin edge cases

View file

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

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': patch
---
fixed css selector syntax for disabled [slot=input]'

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': patch
---
form groups support lazily rendered children

View file

@ -0,0 +1,6 @@
---
'@lion/combobox': patch
'@lion/listbox': patch
---
syncs last selected choice value for [autocomplet="none|list"] on close

View file

@ -13,7 +13,7 @@ availability of the popup.
> Fore more information, consult [Combobox wai-aria design pattern](https://www.w3.org/TR/wai-aria-practices/#combobox)
```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`
<lion-combobox
.showAllOnEmpty="${true}"
id="combobox"
@input="${this.fetchMyDataAndRender}"
.helpText="Returned from server: [${this.options.join(', ')}]"
>
<label slot="label" aria-live="polite"
>Server side completion ${this.loading
? html`<span style="font-style: italic;">(loading...)</span>`
: ''}</label
>
${repeat(
this.options,
entry => entry,
entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `,
)}
</lion-combobox>
`;
}
}
customElements.define('demo-server-side', DemoServerSide);
export const serverSideCompletion = () => html`<demo-server-side></demo-server-side>`;
```
## Listbox compatibility
All configurations that can be applied to `lion-listbox`, can be applied to `lion-combobox` as well.

View file

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

View file

@ -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`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
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`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
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`
<lion-combobox name="foo">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
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`
<lion-combobox name="foo" autocomplete="list" show-all-on-empty>
@ -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`
<lion-combobox name="foo" .showAllOnEmpty="${true}">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
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`
<lion-combobox name="foo" .showAllOnEmpty="${true}">
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
</lion-combobox>
`));
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`
<lion-combobox name="foo" show-all-on-empty>
<lion-option .choiceValue="${'Aha'}" checked>Aha</lion-option>
<lion-option .choiceValue="${'Bhb'}">Bhb</lion-option>
</lion-combobox>
`));
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`
<lion-combobox id="combobox" label="Server side completion">
${this.options.map(
option => html` <lion-option .choiceValue="${option}">${option}</lion-option> `,
)}
</lion-combobox>
`;
}
}
const tagName = defineCE(MyEl);
const wrappingTag = unsafeStatic(tagName);
it('calls "_handleAutocompletion" after externally changing options', async () => {
const el = /** @type {MyEl} */ (await fixture(html`<${wrappingTag}></${wrappingTag}>`));
await el.combobox.registrationComplete;
// @ts-ignore [allow-protected] in test
const spy = sinon.spy(el.combobox, '_handleAutocompletion');
el.addOption();
await el.updateComplete;
await el.updateComplete;
expect(spy).to.have.been.calledOnce;
el.clearOptions();
await el.updateComplete;
await el.updateComplete;
expect(spy).to.have.been.calledTwice;
});
});
describe('Subclassers', () => {
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`
<lion-combobox name="foo" multiple-choice>
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
@ -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);
});
});

View file

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

View file

@ -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];
}
}
}
/**

View file

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

View file

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

View file

@ -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
* <sel-ect>
* <opt-ion></opt-ion>
* </sel-ect>
* # desired end state
* <sel-ect>
* <div role="listbox" slot="lisbox">
* <opt-ion></opt-ion>
* </div>
* </sel-ect>
* @param {HTMLElement} source host of ShadowRoot with default <slot>
* @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);
});
}
}

View file

@ -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` <lion-option .choiceValue="${option}">${option}</lion-option> `,
)}
</${tag}>
<${tag} id="withRepeat">
${repeat(
this.options,
option => option,
option => html` <lion-option .choiceValue="${option}">${option}</lion-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` <lion-option .choiceValue="${option}">${option}</lion-option> `,
)}
</${tag}>
`;
}
}
const tagName = defineCE(MyEl);
const wrappingTag = unsafeStatic(tagName);
it('calls "_onListboxContentChanged" after externally changing options', async () => {
const el = /** @type {MyEl} */ (await _fixture(html`<${wrappingTag}></${wrappingTag}>`));
await el.listbox.registrationComplete;
// @ts-ignore [allow-protected] in test
const spy = sinon.spy(el.listbox, '_onListboxContentChanged');
el.addOption();
await el.updateComplete;
expect(spy).to.have.been.calledOnce;
el.clearOptions();
await el.updateComplete;
expect(spy).to.have.been.calledTwice;
});
});
});
}

View file

@ -74,6 +74,8 @@ export declare class ListboxHost {
protected _onChildActiveChanged(ev: Event): void;
protected get _activeDescendantOwnerNode(): HTMLElement;
protected _onListboxContentChanged(): void;
}
export declare function ListboxImplementation<T extends Constructor<LitElement>>(

View file

@ -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 */

View file

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

View file

@ -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}>
<div slot="content">content</div>
@ -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' }}">
<div slot="content">content</div>
<button slot="invoker">invoker button</button>
</${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;

View file

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