Merge pull request #1341 from ing-bank/fix/listboxDynamicOptions
Super Mega Fix and Feature Pack™
This commit is contained in:
commit
4450199407
21 changed files with 895 additions and 130 deletions
5
.changeset/calm-paws-taste.md
Normal file
5
.changeset/calm-paws-taste.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/overlays': minor
|
||||
---
|
||||
|
||||
expose "repositionOverlay()" on OverlayMixin
|
||||
5
.changeset/calm-pots-tell.md
Normal file
5
.changeset/calm-pots-tell.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/listbox': patch
|
||||
---
|
||||
|
||||
teleported options compatible with map/repeat for listbox/combobox/select-rich
|
||||
5
.changeset/cold-papayas-judge.md
Normal file
5
.changeset/cold-papayas-judge.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/combobox': patch
|
||||
---
|
||||
|
||||
do not reopen listbox on focusin edge cases
|
||||
5
.changeset/long-foxes-smash.md
Normal file
5
.changeset/long-foxes-smash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/combobox': minor
|
||||
---
|
||||
|
||||
automatically recompute autocompletion features when options change (needed for server side completion support)
|
||||
5
.changeset/lovely-tables-scream.md
Normal file
5
.changeset/lovely-tables-scream.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/form-core': patch
|
||||
---
|
||||
|
||||
fixed css selector syntax for disabled [slot=input]'
|
||||
5
.changeset/nasty-scissors-fix.md
Normal file
5
.changeset/nasty-scissors-fix.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/form-core': patch
|
||||
---
|
||||
|
||||
form groups support lazily rendered children
|
||||
6
.changeset/three-apples-appear.md
Normal file
6
.changeset/three-apples-appear.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@lion/combobox': patch
|
||||
'@lion/listbox': patch
|
||||
---
|
||||
|
||||
syncs last selected choice value for [autocomplet="none|list"] on close
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>(
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
19
packages/overlays/types/OverlayMixinTypes.d.ts
vendored
19
packages/overlays/types/OverlayMixinTypes.d.ts
vendored
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue