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)
|
> Fore more information, consult [Combobox wai-aria design pattern](https://www.w3.org/TR/wai-aria-practices/#combobox)
|
||||||
|
|
||||||
```js script
|
```js script
|
||||||
import { html } from '@lion/core';
|
import { LitElement, html, repeat } from '@lion/core';
|
||||||
import { listboxData } from '../../../../packages/listbox/docs/listboxData.js';
|
import { listboxData } from '../../../../packages/listbox/docs/listboxData.js';
|
||||||
import '@lion/listbox/define';
|
import '@lion/listbox/define';
|
||||||
import '@lion/combobox/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
|
## Listbox compatibility
|
||||||
|
|
||||||
All configurations that can be applied to `lion-listbox`, can be applied to `lion-combobox` as well.
|
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
|
* @param {unknown} oldValue
|
||||||
*/
|
*/
|
||||||
requestUpdateInternal(name, 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) {
|
updated(changedProperties) {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
if (changedProperties.has('focused')) {
|
|
||||||
if (this.focused) {
|
if (changedProperties.has('__shouldAutocompleteNextUpdate')) {
|
||||||
this.__requestShowOverlay();
|
// This check should take place before those below of 'opened' and
|
||||||
}
|
// '__shouldAutocompleteNextUpdate', to avoid race conditions
|
||||||
|
this.__unsyncCheckedIndexOnInputChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProperties.has('opened')) {
|
if (changedProperties.has('opened')) {
|
||||||
|
|
@ -413,12 +428,18 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* return options.currentValue.length > 4 && super._showOverlayCondition(options);
|
* return options.currentValue.length > 4 && super._showOverlayCondition(options);
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* @param {{ currentValue: string, lastKey:string|undefined }} options
|
* @param {{ currentValue?: string, lastKey?: string }} options
|
||||||
* @protected
|
* @protected
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
|
// TODO: batch all pending condition triggers in __pendingShowTriggers, reducing race conditions
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
_showOverlayCondition({ lastKey }) {
|
_showOverlayCondition({ lastKey }) {
|
||||||
|
const hideOn = ['Tab', 'Escape', 'Enter'];
|
||||||
|
if (lastKey && hideOn.includes(lastKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.showAllOnEmpty && this.focused) {
|
if (this.showAllOnEmpty && this.focused) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -426,17 +447,21 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
if (!lastKey) {
|
if (!lastKey) {
|
||||||
return /** @type {boolean} */ (this.opened);
|
return /** @type {boolean} */ (this.opened);
|
||||||
}
|
}
|
||||||
const doNotShowOn = ['Tab', 'Esc', 'Enter'];
|
return true;
|
||||||
return !doNotShowOn.includes(lastKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @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
|
* @protected
|
||||||
*/
|
*/
|
||||||
|
_onListboxContentChanged() {
|
||||||
|
super._onListboxContentChanged();
|
||||||
|
this.__shouldAutocompleteNextUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
_textboxOnInput(ev) {
|
_textboxOnInput() {
|
||||||
// Schedules autocompletion of options
|
|
||||||
this.__shouldAutocompleteNextUpdate = true;
|
this.__shouldAutocompleteNextUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,7 +469,10 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* @param {KeyboardEvent} ev
|
* @param {KeyboardEvent} ev
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this, no-unused-vars
|
||||||
_textboxOnKeydown(ev) {
|
_textboxOnKeydown(ev) {
|
||||||
|
// N.B. the check in _showOverlayCondition() is on keyup, and there is a subtle difference
|
||||||
|
// (see tests)
|
||||||
if (ev.key === 'Tab') {
|
if (ev.key === 'Tab') {
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
}
|
}
|
||||||
|
|
@ -456,11 +484,12 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
*/
|
*/
|
||||||
_listboxOnClick(ev) {
|
_listboxOnClick(ev) {
|
||||||
super._listboxOnClick(ev);
|
super._listboxOnClick(ev);
|
||||||
|
|
||||||
|
this._inputNode.focus();
|
||||||
if (!this.multipleChoice) {
|
if (!this.multipleChoice) {
|
||||||
this.activeIndex = -1;
|
this.activeIndex = -1;
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
}
|
}
|
||||||
this._inputNode.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -577,13 +606,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
_handleAutocompletion() {
|
_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 hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart;
|
||||||
|
|
||||||
const curValue = this._inputNode.value;
|
const curValue = this._inputNode.value;
|
||||||
|
|
@ -604,7 +626,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
const isInlineAutoFillCandidate =
|
const isInlineAutoFillCandidate =
|
||||||
this.autocomplete === 'both' || this.autocomplete === 'inline';
|
this.autocomplete === 'both' || this.autocomplete === 'inline';
|
||||||
const autoselect = this._autoSelectCondition();
|
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';
|
const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none';
|
||||||
|
|
||||||
/** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */
|
/** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */
|
||||||
|
|
@ -754,6 +775,15 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
this.__setupCombobox();
|
this.__setupCombobox();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enhance OverlayMixin
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
_teardownOverlayCtrl() {
|
||||||
|
super._teardownOverlayCtrl();
|
||||||
|
this.__teardownCombobox();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @enhance OverlayMixin
|
* @enhance OverlayMixin
|
||||||
* @protected
|
* @protected
|
||||||
|
|
@ -889,8 +919,9 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
__requestShowOverlay(ev) {
|
__requestShowOverlay(ev) {
|
||||||
|
const lastKey = ev && ev.key;
|
||||||
this.opened = this._showOverlayCondition({
|
this.opened = this._showOverlayCondition({
|
||||||
lastKey: ev && ev.key,
|
lastKey,
|
||||||
currentValue: this._inputNode.value,
|
currentValue: this._inputNode.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ function getComboboxMembers(el) {
|
||||||
* @param {LionCombobox} el
|
* @param {LionCombobox} el
|
||||||
* @param {string} value
|
* @param {string} value
|
||||||
*/
|
*/
|
||||||
|
// TODO: add keys that actually make sense...
|
||||||
function mimicUserTyping(el, value) {
|
function mimicUserTyping(el, value) {
|
||||||
const { _inputNode } = getComboboxMembers(el);
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
_inputNode.dispatchEvent(new Event('focusin', { bubbles: true }));
|
_inputNode.dispatchEvent(new Event('focusin', { bubbles: true }));
|
||||||
|
|
@ -57,7 +58,7 @@ function mimicUserTyping(el, value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HTMLInputElement} el
|
* @param {HTMLElement} el
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
*/
|
*/
|
||||||
function mimicKeyPress(el, key) {
|
function mimicKeyPress(el, key) {
|
||||||
|
|
@ -71,45 +72,44 @@ function mimicKeyPress(el, key) {
|
||||||
*/
|
*/
|
||||||
async function mimicUserTypingAdvanced(el, values) {
|
async function mimicUserTypingAdvanced(el, values) {
|
||||||
const { _inputNode } = getComboboxMembers(el);
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
const inputNodeLoc = /** @type {HTMLInputElement & {selectionStart:number, selectionEnd:number}} */ (_inputNode);
|
_inputNode.dispatchEvent(new Event('focusin', { bubbles: true }));
|
||||||
inputNodeLoc.dispatchEvent(new Event('focusin', { bubbles: true }));
|
|
||||||
|
|
||||||
for (const key of values) {
|
for (const key of values) {
|
||||||
// eslint-disable-next-line no-await-in-loop, no-loop-func
|
// eslint-disable-next-line no-await-in-loop, no-loop-func
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
const hasSelection = inputNodeLoc.selectionStart !== inputNodeLoc.selectionEnd;
|
const hasSelection = _inputNode.selectionStart !== _inputNode.selectionEnd;
|
||||||
|
|
||||||
if (key === 'Backspace') {
|
if (key === 'Backspace') {
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
inputNodeLoc.value =
|
_inputNode.value =
|
||||||
inputNodeLoc.value.slice(
|
_inputNode.value.slice(
|
||||||
0,
|
0,
|
||||||
inputNodeLoc.selectionStart ? inputNodeLoc.selectionStart : undefined,
|
_inputNode.selectionStart ? _inputNode.selectionStart : undefined,
|
||||||
) +
|
) +
|
||||||
inputNodeLoc.value.slice(
|
_inputNode.value.slice(
|
||||||
inputNodeLoc.selectionEnd ? inputNodeLoc.selectionEnd : undefined,
|
_inputNode.selectionEnd ? _inputNode.selectionEnd : undefined,
|
||||||
inputNodeLoc.value.length,
|
_inputNode.value.length,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
inputNodeLoc.value = inputNodeLoc.value.slice(0, -1);
|
_inputNode.value = _inputNode.value.slice(0, -1);
|
||||||
}
|
}
|
||||||
} else if (hasSelection) {
|
} else if (hasSelection) {
|
||||||
inputNodeLoc.value =
|
_inputNode.value =
|
||||||
inputNodeLoc.value.slice(
|
_inputNode.value.slice(
|
||||||
0,
|
0,
|
||||||
inputNodeLoc.selectionStart ? inputNodeLoc.selectionStart : undefined,
|
_inputNode.selectionStart ? _inputNode.selectionStart : undefined,
|
||||||
) +
|
) +
|
||||||
key +
|
key +
|
||||||
inputNodeLoc.value.slice(
|
_inputNode.value.slice(
|
||||||
inputNodeLoc.selectionEnd ? inputNodeLoc.selectionEnd : undefined,
|
_inputNode.selectionEnd ? _inputNode.selectionEnd : undefined,
|
||||||
inputNodeLoc.value.length,
|
_inputNode.value.length,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
inputNodeLoc.value += key;
|
_inputNode.value += key;
|
||||||
}
|
}
|
||||||
|
|
||||||
mimicKeyPress(inputNodeLoc, key);
|
mimicKeyPress(_inputNode, key);
|
||||||
inputNodeLoc.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
_inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||||
|
|
||||||
el.updateComplete.then(() => {
|
el.updateComplete.then(() => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
@ -157,41 +157,72 @@ async function fruitFixture({ autocomplete, matchMode } = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('lion-combobox', () => {
|
describe('lion-combobox', () => {
|
||||||
describe('Options', () => {
|
describe('Options visibility', () => {
|
||||||
describe('showAllOnEmpty', () => {
|
it('hides options when text in input node is cleared after typing something by default', async () => {
|
||||||
it('hides options when text in input node is cleared after typing something by default', async () => {
|
const el = /** @type {LionCombobox} */ (await fixture(html`
|
||||||
const el = /** @type {LionCombobox} */ (await fixture(html`
|
<lion-combobox name="foo">
|
||||||
<lion-combobox name="foo">
|
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
||||||
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
|
||||||
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
|
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
|
||||||
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
|
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
|
||||||
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
|
</lion-combobox>
|
||||||
</lion-combobox>
|
`));
|
||||||
`));
|
|
||||||
|
|
||||||
const options = el.formElements;
|
const options = el.formElements;
|
||||||
const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
|
const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
|
||||||
|
|
||||||
async function performChecks() {
|
async function performChecks() {
|
||||||
mimicUserTyping(el, 'c');
|
mimicUserTyping(el, 'c');
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(visibleOptions().length).to.equal(4);
|
expect(visibleOptions().length).to.equal(4);
|
||||||
mimicUserTyping(el, '');
|
mimicUserTyping(el, '');
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(visibleOptions().length).to.equal(0);
|
expect(visibleOptions().length).to.equal(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: autocomplete 'none' should have this behavior as well
|
el.autocomplete = 'none';
|
||||||
// el.autocomplete = 'none';
|
await performChecks();
|
||||||
// await performChecks();
|
el.autocomplete = 'list';
|
||||||
el.autocomplete = 'list';
|
await performChecks();
|
||||||
await performChecks();
|
el.autocomplete = 'inline';
|
||||||
el.autocomplete = 'inline';
|
await performChecks();
|
||||||
await performChecks();
|
el.autocomplete = 'both';
|
||||||
el.autocomplete = 'both';
|
await performChecks();
|
||||||
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 () => {
|
it('keeps showing options when text in input node is cleared after typing something', async () => {
|
||||||
const el = /** @type {LionCombobox} */ (await fixture(html`
|
const el = /** @type {LionCombobox} */ (await fixture(html`
|
||||||
<lion-combobox name="foo" autocomplete="list" show-all-on-empty>
|
<lion-combobox name="foo" autocomplete="list" show-all-on-empty>
|
||||||
|
|
@ -240,6 +271,71 @@ describe('lion-combobox', () => {
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
expect(el.opened).to.be.true;
|
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(el2.modelValue).to.eql([]);
|
||||||
expect(_inputNode.value).to.equal('');
|
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', () => {
|
describe('Overlay visibility', () => {
|
||||||
|
|
@ -464,7 +592,9 @@ describe('lion-combobox', () => {
|
||||||
expect(el.opened).to.equal(true);
|
expect(el.opened).to.equal(true);
|
||||||
expect(_inputNode.value).to.equal('Artichoke');
|
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(el.opened).to.equal(false);
|
||||||
expect(_inputNode.value).to.equal('Artichoke');
|
expect(_inputNode.value).to.equal('Artichoke');
|
||||||
});
|
});
|
||||||
|
|
@ -1209,6 +1339,60 @@ describe('lion-combobox', () => {
|
||||||
expect(_inputNode.selectionEnd).to.equal('Ch'.length);
|
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', () => {
|
describe('Subclassers', () => {
|
||||||
it('allows to configure autoselect', async () => {
|
it('allows to configure autoselect', async () => {
|
||||||
class X extends LionCombobox {
|
class X extends LionCombobox {
|
||||||
|
|
@ -1712,7 +1896,7 @@ describe('lion-combobox', () => {
|
||||||
|
|
||||||
describe('Multiple Choice', () => {
|
describe('Multiple Choice', () => {
|
||||||
// TODO: possibly later share test with select-rich if it officially supports multipleChoice
|
// 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`
|
const el = /** @type {LionCombobox} */ (await fixture(html`
|
||||||
<lion-combobox name="foo" multiple-choice>
|
<lion-combobox name="foo" multiple-choice>
|
||||||
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
||||||
|
|
@ -1733,10 +1917,8 @@ describe('lion-combobox', () => {
|
||||||
const visibleOptions = el.formElements.filter(o => o.style.display !== 'none');
|
const visibleOptions = el.formElements.filter(o => o.style.display !== 'none');
|
||||||
visibleOptions[0].click();
|
visibleOptions[0].click();
|
||||||
expect(el.opened).to.equal(true);
|
expect(el.opened).to.equal(true);
|
||||||
// visibleOptions[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
|
mimicKeyPress(visibleOptions[1], 'Enter');
|
||||||
// expect(el.opened).to.equal(true);
|
expect(el.opened).to.equal(true);
|
||||||
// visibleOptions[2].dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
|
|
||||||
// expect(el.opened).to.equal(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -684,7 +684,7 @@ const FormControlMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/***** {state} :disabled *****/
|
/***** {state} :disabled *****/
|
||||||
:host([disabled]) .input-group ::slotted(slot='input') {
|
:host([disabled]) .input-group ::slotted([slot='input']) {
|
||||||
color: var(--disabled-text-color, #767676);
|
color: var(--disabled-text-color, #767676);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,9 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
this.defaultValidators = [new FormElementsHaveNoError()];
|
this.defaultValidators = [new FormElementsHaveNoError()];
|
||||||
|
|
||||||
this.__descriptionElementsInParentChain = new Set();
|
this.__descriptionElementsInParentChain = new Set();
|
||||||
|
|
||||||
|
/** @type {{modelValue?:{[key:string]: any}, serializedValue?:{[key:string]: any}}} */
|
||||||
|
this.__pendingValues = { modelValue: {}, serializedValue: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -349,6 +352,8 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
if (this.formElements[name]) {
|
if (this.formElements[name]) {
|
||||||
this.formElements[name][property] = values[name];
|
this.formElements[name][property] = values[name];
|
||||||
|
} else {
|
||||||
|
this.__pendingValues[property][name] = values[name];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -485,7 +490,7 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
* @override of FormRegistrarMixin.
|
* @override of FormRegistrarMixin.
|
||||||
* @desc Connects ValidateMixin and DisabledMixin
|
* @desc Connects ValidateMixin and DisabledMixin
|
||||||
* On top of this, error messages of children are linked to their parents
|
* 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
|
* @param {number} indexToInsertAt
|
||||||
*/
|
*/
|
||||||
addFormElement(child, indexToInsertAt) {
|
addFormElement(child, indexToInsertAt) {
|
||||||
|
|
@ -502,6 +507,16 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
if (typeof child.addToAriaLabelledBy === 'function' && this._labelNode) {
|
if (typeof child.addToAriaLabelledBy === 'function' && this._labelNode) {
|
||||||
child.addToAriaLabelledBy(this._labelNode, { reorder: false });
|
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 { localizeTearDown } from '@lion/localize/test-helpers';
|
||||||
import {
|
import {
|
||||||
defineCE,
|
defineCE,
|
||||||
|
|
@ -1165,5 +1165,224 @@ export function runFormGroupMixinSuite(cfg = {}) {
|
||||||
expect(el.getAttribute('aria-labelledby')).contains(label.id);
|
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) {
|
if (type === 'OptionChoiceField' && testKeyboardBehavior) {
|
||||||
resetChoiceFieldToForceRepropagation(formControl);
|
resetChoiceFieldToForceRepropagation(formControl);
|
||||||
mimicUserInput(formControl, 'userValue', 'keypress');
|
mimicUserInput(formControl, 'userValue', 'keypress');
|
||||||
|
// TODO: get rid of try/catch (?)...
|
||||||
try {
|
try {
|
||||||
expect(spy.firstCall.args[0].detail.isTriggeredByUser).to.be.true;
|
expect(spy.firstCall.args[0].detail.isTriggeredByUser).to.be.true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,41 @@ import { LionOptions } from './LionOptions.js';
|
||||||
* @typedef {import('@lion/form-core/types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
|
* @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() {
|
function uuid() {
|
||||||
return Math.random().toString(36).substr(2, 10);
|
return Math.random().toString(36).substr(2, 10);
|
||||||
}
|
}
|
||||||
|
|
@ -344,6 +379,12 @@ const ListboxMixinImplementation = superclass =>
|
||||||
/** @type {any[]} */
|
/** @type {any[]} */
|
||||||
this._initialModelValue = this.modelValue;
|
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() {
|
_teardownListboxNode() {
|
||||||
if (this._listboxNode) {
|
if (this._listboxNode) {
|
||||||
this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
|
this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
|
||||||
|
|
@ -821,13 +870,9 @@ const ListboxMixinImplementation = superclass =>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (slot) {
|
if (slot) {
|
||||||
slot.assignedNodes().forEach(node => {
|
moveDefaultSlottablesToTarget(this, this._listboxNode);
|
||||||
this._listboxNode.appendChild(node);
|
|
||||||
});
|
|
||||||
slot.addEventListener('slotchange', () => {
|
slot.addEventListener('slotchange', () => {
|
||||||
slot.assignedNodes().forEach(node => {
|
moveDefaultSlottablesToTarget(this, this._listboxNode);
|
||||||
this._listboxNode.appendChild(node);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import '@lion/core/differentKeyEventNamesShimIE';
|
import '@lion/core/differentKeyEventNamesShimIE';
|
||||||
|
import { repeat, LitElement } from '@lion/core';
|
||||||
import { Required } from '@lion/form-core';
|
import { Required } from '@lion/form-core';
|
||||||
import { LionOptions } from '@lion/listbox';
|
import { LionOptions } from '@lion/listbox';
|
||||||
import '@lion/listbox/define';
|
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 sinon from 'sinon';
|
||||||
import { getListboxMembers } from '../test-helpers/index.js';
|
import { getListboxMembers } from '../test-helpers/index.js';
|
||||||
|
|
||||||
|
|
@ -24,23 +25,6 @@ function mimicKeyPress(el, key) {
|
||||||
el.dispatchEvent(new KeyboardEvent('keyup', { 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]
|
* @param { {tagString?:string, optionTagString?:string} } [customConfig]
|
||||||
*/
|
*/
|
||||||
|
|
@ -381,7 +365,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
|
|
||||||
it('has a reference to the active option', async () => {
|
it('has a reference to the active option', async () => {
|
||||||
const el = await fixture(html`
|
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=${'10'} id="first">Item 1</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${'20'} checked id="second">Item 2</${optionTag}>
|
<${optionTag} .choiceValue=${'20'} checked id="second">Item 2</${optionTag}>
|
||||||
</${tag}>
|
</${tag}>
|
||||||
|
|
@ -403,7 +387,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
|
|
||||||
it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
|
it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
|
||||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
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=${10}>Item 1</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${30}>Item 3</${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 () => {
|
it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<${tag} autocomplete="none">
|
<${tag} autocomplete="none" show-all-on-empty>
|
||||||
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
||||||
|
|
@ -604,7 +588,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
describe('Enter', () => {
|
describe('Enter', () => {
|
||||||
it('[Enter] selects active option', async () => {
|
it('[Enter] selects active option', async () => {
|
||||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
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="${'Artichoke'}">Artichoke</${optionTag}>
|
||||||
<${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}>
|
<${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}>
|
||||||
<${optionTag} .choiceValue="${'Chard'}">Chard</${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
|
// When listbox is not focusable (in case of a combobox), the user should be allowed
|
||||||
// to enter a space in the focusable element (texbox)
|
// to enter a space in the focusable element (texbox)
|
||||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
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="${'Artichoke'}">Artichoke</${optionTag}>
|
||||||
<${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}>
|
<${optionTag} .choiceValue="${'Bla'}">Bla</${optionTag}>
|
||||||
<${optionTag} .choiceValue="${'Chard'}">Chard</${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 () => {
|
it('navigates through open lists with [ArrowDown] [ArrowUp] keys activates the option', async () => {
|
||||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
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 1'}>Item 1</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${'Item 2'}>Item 2</${optionTag}>
|
<${optionTag} .choiceValue=${'Item 2'}>Item 2</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${'Item 3'}>Item 3</${optionTag}>
|
<${optionTag} .choiceValue=${'Item 3'}>Item 3</${optionTag}>
|
||||||
|
|
@ -731,7 +715,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
describe('Orientation', () => {
|
describe('Orientation', () => {
|
||||||
it('has a default value of "vertical"', async () => {
|
it('has a default value of "vertical"', async () => {
|
||||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
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="${'Artichoke'}">Artichoke</${optionTag}>
|
||||||
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
||||||
</${tag}>
|
</${tag}>
|
||||||
|
|
@ -771,7 +755,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
|
|
||||||
it('uses [ArrowLeft] and [ArrowRight] keys when "horizontal"', async () => {
|
it('uses [ArrowLeft] and [ArrowRight] keys when "horizontal"', async () => {
|
||||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
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="${'Artichoke'}">Artichoke</${optionTag}>
|
||||||
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
||||||
</${tag}>
|
</${tag}>
|
||||||
|
|
@ -948,7 +932,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
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=${10}>Item 1</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
||||||
|
|
@ -988,7 +972,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
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=${10}>Item 1</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
||||||
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
||||||
|
|
@ -1384,5 +1368,146 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
expect(clickSpy).to.not.have.been.called;
|
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 _onChildActiveChanged(ev: Event): void;
|
||||||
|
|
||||||
protected get _activeDescendantOwnerNode(): HTMLElement;
|
protected get _activeDescendantOwnerNode(): HTMLElement;
|
||||||
|
|
||||||
|
protected _onListboxContentChanged(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare function ListboxImplementation<T extends Constructor<LitElement>>(
|
export declare function ListboxImplementation<T extends Constructor<LitElement>>(
|
||||||
|
|
|
||||||
|
|
@ -471,8 +471,6 @@ export class OverlayController extends EventTargetShim {
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
this.__validateConfiguration(/** @type {OverlayConfig} */ (this.config));
|
this.__validateConfiguration(/** @type {OverlayConfig} */ (this.config));
|
||||||
// TODO: remove this, so we only have the getters (no setters)
|
|
||||||
// Object.assign(this, this.config);
|
|
||||||
/** @protected */
|
/** @protected */
|
||||||
this._init({ cfgToAdd });
|
this._init({ cfgToAdd });
|
||||||
/** @private */
|
/** @private */
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,6 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
_defineOverlay({ contentNode, invokerNode, referenceNode, backdropNode, contentWrapperNode }) {
|
_defineOverlay({ contentNode, invokerNode, referenceNode, backdropNode, contentWrapperNode }) {
|
||||||
const overlayConfig = this._defineOverlayConfig() || {};
|
const overlayConfig = this._defineOverlayConfig() || {};
|
||||||
|
|
||||||
return new OverlayController({
|
return new OverlayController({
|
||||||
contentNode,
|
contentNode,
|
||||||
invokerNode,
|
invokerNode,
|
||||||
|
|
@ -353,5 +352,17 @@ export const OverlayMixinImplementation = superclass =>
|
||||||
async close() {
|
async close() {
|
||||||
await /** @type {OverlayController} */ (this._overlayCtrl).hide();
|
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);
|
export const OverlayMixin = dedupeMixin(OverlayMixinImplementation);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing';
|
import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { overlays } from '../src/overlays.js';
|
import { overlays } from '../src/overlays.js';
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
import { OverlayController } from '../src/OverlayController.js';
|
import { OverlayController } from '../src/OverlayController.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -171,7 +170,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
|
||||||
expect(el.opened).to.be.true;
|
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) {
|
function preventer(/** @type Event */ ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +214,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// See https://github.com/ing-bank/lion/discussions/1095
|
// 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`
|
const el = /** @type {OverlayEl} */ (await fixture(html`
|
||||||
<${tag}>
|
<${tag}>
|
||||||
<div slot="content">content</div>
|
<div slot="content">content</div>
|
||||||
|
|
@ -240,6 +239,25 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
|
||||||
expect(el.opened).to.be.false;
|
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 */
|
/** See: https://github.com/ing-bank/lion/issues/1075 */
|
||||||
it('stays open after config update', async () => {
|
it('stays open after config update', async () => {
|
||||||
const el = /** @type {OverlayEl} */ (await fixture(html`
|
const el = /** @type {OverlayEl} */ (await fixture(html`
|
||||||
|
|
@ -250,6 +268,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
|
||||||
`));
|
`));
|
||||||
el.open();
|
el.open();
|
||||||
await el._overlayCtrl._showComplete;
|
await el._overlayCtrl._showComplete;
|
||||||
|
|
||||||
el.config = { ...el.config, hidesOnOutsideClick: !el.config.hidesOnOutsideClick };
|
el.config = { ...el.config, hidesOnOutsideClick: !el.config.hidesOnOutsideClick };
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
expect(el.opened).to.be.true;
|
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 {
|
export declare class OverlayHost {
|
||||||
public opened: Boolean;
|
opened: Boolean;
|
||||||
|
get config(): OverlayConfig;
|
||||||
|
set config(value: OverlayConfig);
|
||||||
|
|
||||||
public get config(): OverlayConfig;
|
open(): void;
|
||||||
public set config(value: OverlayConfig);
|
close(): void;
|
||||||
|
toggle(): void;
|
||||||
public open(): void;
|
/**
|
||||||
public close(): void;
|
* Sometimes it's needed to recompute Popper position of an overlay, for instance when we have
|
||||||
public toggle(): void;
|
* an opened combobox and the surrounding context changes (the space consumed by the textbox
|
||||||
|
* increases vertically)
|
||||||
|
*/
|
||||||
|
repositionOverlay(): void;
|
||||||
|
|
||||||
protected _overlayCtrl: OverlayController;
|
protected _overlayCtrl: OverlayController;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue