feat: support (multiselect) choice-groups with allow-custom-choice
This commit is contained in:
parent
c459ded9d8
commit
be4e25a108
21 changed files with 900 additions and 107 deletions
5
.changeset/dirty-emus-exercise.md
Normal file
5
.changeset/dirty-emus-exercise.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ui': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix: fixes single-choice, requireOptionMatch=false to not clear selection
|
||||||
6
.changeset/ten-maps-tap.md
Normal file
6
.changeset/ten-maps-tap.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
'publish-docs': patch
|
||||||
|
'@lion/ui': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feature: Added support to multiselect and require option=false at the same time for lion-combobox
|
||||||
|
|
@ -25,7 +25,7 @@ export class DemoSelectionDisplay extends LitElement {
|
||||||
* Can be used to visually indicate the next
|
* Can be used to visually indicate the next
|
||||||
*/
|
*/
|
||||||
removeChipOnNextBackspace: Boolean,
|
removeChipOnNextBackspace: Boolean,
|
||||||
selectedElements: Array,
|
selectedChoices: Array,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,12 +72,6 @@ export class DemoSelectionDisplay extends LitElement {
|
||||||
return this.comboboxElement._inputNode;
|
return this.comboboxElement._inputNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
_computeSelectedElements() {
|
|
||||||
const { formElements, checkedIndex } = /** @type {LionCombobox} */ (this.comboboxElement);
|
|
||||||
const checkedIndexes = Array.isArray(checkedIndex) ? checkedIndex : [checkedIndex];
|
|
||||||
return formElements.filter((_, i) => checkedIndexes.includes(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
get multipleChoice() {
|
get multipleChoice() {
|
||||||
return this.comboboxElement?.multipleChoice;
|
return this.comboboxElement?.multipleChoice;
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +79,7 @@ export class DemoSelectionDisplay extends LitElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.selectedElements = [];
|
this.selectedChoices = [];
|
||||||
|
|
||||||
/** @type {EventListener} */
|
/** @type {EventListener} */
|
||||||
this.__textboxOnKeyup = this.__textboxOnKeyup.bind(this);
|
this.__textboxOnKeyup = this.__textboxOnKeyup.bind(this);
|
||||||
|
|
@ -110,31 +104,10 @@ export class DemoSelectionDisplay extends LitElement {
|
||||||
*/
|
*/
|
||||||
onComboboxElementUpdated(changedProperties) {
|
onComboboxElementUpdated(changedProperties) {
|
||||||
if (changedProperties.has('modelValue')) {
|
if (changedProperties.has('modelValue')) {
|
||||||
this.selectedElements = this._computeSelectedElements();
|
this.selectedChoices = this.comboboxElement.modelValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whenever selectedElements are updated, makes sure that latest added elements
|
|
||||||
* are shown latest, and deleted elements respect existing order of chips.
|
|
||||||
*/
|
|
||||||
__reorderChips() {
|
|
||||||
const { selectedElements } = this;
|
|
||||||
if (this.__prevSelectedEls) {
|
|
||||||
const addedEls = selectedElements.filter(e => !this.__prevSelectedEls.includes(e));
|
|
||||||
const deletedEls = this.__prevSelectedEls.filter(e => !selectedElements.includes(e));
|
|
||||||
if (addedEls.length) {
|
|
||||||
this.selectedElements = [...this.__prevSelectedEls, ...addedEls];
|
|
||||||
} else if (deletedEls.length) {
|
|
||||||
deletedEls.forEach(delEl => {
|
|
||||||
this.__prevSelectedEls.splice(this.__prevSelectedEls.indexOf(delEl), 1);
|
|
||||||
});
|
|
||||||
this.selectedElements = this.__prevSelectedEls;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.__prevSelectedEls = this.selectedElements;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("@lion/listbox").LionOption} option
|
* @param {import("@lion/listbox").LionOption} option
|
||||||
* @param {boolean} highlight
|
* @param {boolean} highlight
|
||||||
|
|
@ -143,7 +116,7 @@ export class DemoSelectionDisplay extends LitElement {
|
||||||
_selectedElementTemplate(option, highlight) {
|
_selectedElementTemplate(option, highlight) {
|
||||||
return html`
|
return html`
|
||||||
<span class="selection-chip ${highlight ? 'selection-chip--highlighted' : ''}">
|
<span class="selection-chip ${highlight ? 'selection-chip--highlighted' : ''}">
|
||||||
${option.value}
|
${option}
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -154,9 +127,9 @@ export class DemoSelectionDisplay extends LitElement {
|
||||||
}
|
}
|
||||||
return html`
|
return html`
|
||||||
<div class="combobox__selection">
|
<div class="combobox__selection">
|
||||||
${this.selectedElements.map((option, i) => {
|
${this.selectedChoices.map((option, i) => {
|
||||||
const highlight = Boolean(
|
const highlight = Boolean(
|
||||||
this.removeChipOnNextBackspace && i === this.selectedElements.length - 1,
|
this.removeChipOnNextBackspace && i === this.selectedChoices.length - 1,
|
||||||
);
|
);
|
||||||
return this._selectedElementTemplate(option, highlight);
|
return this._selectedElementTemplate(option, highlight);
|
||||||
})}
|
})}
|
||||||
|
|
@ -174,8 +147,8 @@ export class DemoSelectionDisplay extends LitElement {
|
||||||
__textboxOnKeyup(ev) {
|
__textboxOnKeyup(ev) {
|
||||||
if (ev.key === 'Backspace') {
|
if (ev.key === 'Backspace') {
|
||||||
if (!this._inputNode.value) {
|
if (!this._inputNode.value) {
|
||||||
if (this.removeChipOnNextBackspace && this.selectedElements.length) {
|
if (this.removeChipOnNextBackspace && this.selectedChoices.length) {
|
||||||
this.selectedElements[this.selectedElements.length - 1].checked = false;
|
this.comboboxElement.modelValue = this.selectedChoices.slice(0, -1);
|
||||||
}
|
}
|
||||||
this.removeChipOnNextBackspace = true;
|
this.removeChipOnNextBackspace = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ This will:
|
||||||
> Please note that the lion-combobox-selection-display below is not exposed and only serves
|
> Please note that the lion-combobox-selection-display below is not exposed and only serves
|
||||||
> as an example. The selection part of a multiselect combobox is not yet accessible. Please keep
|
> as an example. The selection part of a multiselect combobox is not yet accessible. Please keep
|
||||||
> in mind that for now, as a Subclasser, you would have to take care of this part yourself.
|
> in mind that for now, as a Subclasser, you would have to take care of this part yourself.
|
||||||
|
> Also keep in mind that the combobox organizes the selected list by its original index in the option list
|
||||||
|
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const multipleChoice = () => html`
|
export const multipleChoice = () => html`
|
||||||
|
|
@ -249,6 +250,28 @@ export const multipleChoice = () => html`
|
||||||
`;
|
`;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alternatively, the multi-choice flag can be combined with .requireMultipleMatch=false to allow users to enter their own options.
|
||||||
|
|
||||||
|
> Note that the non-matching items will be displayed in the end of the list in the order that were entered. Since those have no index
|
||||||
|
> in the option list, they don't have a representing value in the checkedIndex property.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const multipleCustomizableChoice = () => html`
|
||||||
|
<lion-combobox name="combo" label="Multiple" .requireOptionMatch=${false} multiple-choice>
|
||||||
|
<demo-selection-display
|
||||||
|
slot="selection-display"
|
||||||
|
style="display: contents;"
|
||||||
|
></demo-selection-display>
|
||||||
|
${lazyRender(
|
||||||
|
listboxData.map(
|
||||||
|
(entry, i) =>
|
||||||
|
html` <lion-option .choiceValue="${entry}" ?checked=${i === 0}>${entry}</lion-option> `,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</lion-combobox>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
The combobox works with a `Required` validator to check if it is empty.
|
The combobox works with a `Required` validator to check if it is empty.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { OverlayMixin, withDropdownConfig } from '@lion/ui/overlays.js';
|
||||||
import { css, html } from 'lit';
|
import { css, html } from 'lit';
|
||||||
import { makeMatchingTextBold, unmakeMatchingTextBold } from './utils/makeMatchingTextBold.js';
|
import { makeMatchingTextBold, unmakeMatchingTextBold } from './utils/makeMatchingTextBold.js';
|
||||||
import { MatchesOption } from './validators.js';
|
import { MatchesOption } from './validators.js';
|
||||||
|
import { CustomChoiceGroupMixin } from '../../form-core/src/choice-group/CustomChoiceGroupMixin.js';
|
||||||
|
|
||||||
const matchA11ySpanReverseFns = new WeakMap();
|
const matchA11ySpanReverseFns = new WeakMap();
|
||||||
|
|
||||||
|
|
@ -27,7 +28,7 @@ const matchA11ySpanReverseFns = new WeakMap();
|
||||||
* LionCombobox: implements the wai-aria combobox design pattern and integrates it as a Lion
|
* LionCombobox: implements the wai-aria combobox design pattern and integrates it as a Lion
|
||||||
* FormControl
|
* FormControl
|
||||||
*/
|
*/
|
||||||
export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMixin(LionListbox))) {
|
||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -43,6 +44,10 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
||||||
requireOptionMatch: {
|
requireOptionMatch: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
},
|
},
|
||||||
|
allowCustomChoice: {
|
||||||
|
type: Boolean,
|
||||||
|
attribute: 'allow-custom-choice',
|
||||||
|
},
|
||||||
__shouldAutocompleteNextUpdate: Boolean,
|
__shouldAutocompleteNextUpdate: Boolean,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -316,7 +321,9 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
||||||
*/
|
*/
|
||||||
get _inputNode() {
|
get _inputNode() {
|
||||||
if (this._ariaVersion === '1.1' && this._comboboxNode) {
|
if (this._ariaVersion === '1.1' && this._comboboxNode) {
|
||||||
return /** @type {HTMLInputElement} */ (this._comboboxNode.querySelector('input'));
|
return /** @type {HTMLInputElement} */ (
|
||||||
|
this._comboboxNode.querySelector('input') || this._comboboxNode
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return /** @type {HTMLInputElement} */ (this._comboboxNode);
|
return /** @type {HTMLInputElement} */ (this._comboboxNode);
|
||||||
}
|
}
|
||||||
|
|
@ -364,6 +371,20 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
||||||
return this._inputNode;
|
return this._inputNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get requireOptionMatch() {
|
||||||
|
return !this.allowCustomChoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} value
|
||||||
|
*/
|
||||||
|
set requireOptionMatch(value) {
|
||||||
|
this.allowCustomChoice = !value;
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
/**
|
/**
|
||||||
|
|
@ -486,14 +507,20 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts viewValue to modelValue
|
* Converts viewValue to modelValue
|
||||||
* @param {string} value - viewValue: the formatted value inside <input>
|
* @override CustomChoiceGroupMixin
|
||||||
|
* @param {string|string[]} value - viewValue: the formatted value inside <input>
|
||||||
* @returns {*} modelValue
|
* @returns {*} modelValue
|
||||||
*/
|
*/
|
||||||
parser(value) {
|
parser(value) {
|
||||||
if (this.requireOptionMatch && this.checkedIndex === -1 && value !== '') {
|
if (
|
||||||
|
this.requireOptionMatch &&
|
||||||
|
this.checkedIndex === -1 &&
|
||||||
|
value !== '' &&
|
||||||
|
!Array.isArray(value)
|
||||||
|
) {
|
||||||
return new Unparseable(value);
|
return new Unparseable(value);
|
||||||
}
|
}
|
||||||
return value;
|
return super.parser(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -554,15 +581,6 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
||||||
if (typeof this._selectionDisplayNode?.onComboboxElementUpdated === 'function') {
|
if (typeof this._selectionDisplayNode?.onComboboxElementUpdated === 'function') {
|
||||||
this._selectionDisplayNode.onComboboxElementUpdated(changedProperties);
|
this._selectionDisplayNode.onComboboxElementUpdated(changedProperties);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProperties.has('requireOptionMatch') || changedProperties.has('multipleChoice')) {
|
|
||||||
if (!this.requireOptionMatch && this.multipleChoice) {
|
|
||||||
// TODO implement !requireOptionMatch and multipleChoice flow
|
|
||||||
throw new Error(
|
|
||||||
"multipleChoice and requireOptionMatch=false can't be used at the same time (yet).",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -697,8 +715,8 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
_setTextboxValue(v) {
|
_setTextboxValue(v) {
|
||||||
// Make sure that we don't loose inputNode.selectionStart and inputNode.selectionEnd
|
// Make sure that we don't lose inputNode.selectionStart and inputNode.selectionEnd
|
||||||
if (this._inputNode.value !== v) {
|
if (this._inputNode && this._inputNode.value !== v) {
|
||||||
this._inputNode.value = v;
|
this._inputNode.value = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1068,25 +1086,52 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
_listboxOnKeyDown(ev) {
|
_listboxOnKeyDown(ev) {
|
||||||
super._listboxOnKeyDown(ev);
|
|
||||||
const { key } = ev;
|
const { key } = ev;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
|
super._listboxOnKeyDown(ev);
|
||||||
this._setTextboxValue('');
|
this._setTextboxValue('');
|
||||||
break;
|
break;
|
||||||
|
case 'Backspace':
|
||||||
|
case 'Delete':
|
||||||
|
if (this.requireOptionMatch) {
|
||||||
|
super._listboxOnKeyDown(ev);
|
||||||
|
} else {
|
||||||
|
this.opened = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
if (this.multipleChoice && this.opened) {
|
if (this.multipleChoice && this.opened) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
}
|
}
|
||||||
if (!this.formElements[this.activeIndex]) {
|
|
||||||
return;
|
if (
|
||||||
|
!this.requireOptionMatch &&
|
||||||
|
this.multipleChoice &&
|
||||||
|
(!this.formElements[this.activeIndex] ||
|
||||||
|
this.formElements[this.activeIndex].hasAttribute('aria-hidden') ||
|
||||||
|
!this.opened)
|
||||||
|
) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
this.modelValue = this.parser([...this.modelValue, this._inputNode.value]);
|
||||||
|
|
||||||
|
this._inputNode.value = '';
|
||||||
|
this.opened = false;
|
||||||
|
} else {
|
||||||
|
super._listboxOnKeyDown(ev);
|
||||||
|
// TODO: should we clear the input value here when allowCustomChoice is false?
|
||||||
|
// For now, we don't...
|
||||||
}
|
}
|
||||||
if (!this.multipleChoice) {
|
if (!this.multipleChoice) {
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
/* no default */
|
default: {
|
||||||
|
super._listboxOnKeyDown(ev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1113,12 +1158,14 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
_syncToTextboxMultiple(modelValue, oldModelValue = []) {
|
_syncToTextboxMultiple(modelValue, oldModelValue = []) {
|
||||||
const diff = modelValue.filter(x => !oldModelValue.includes(x));
|
if (this.requireOptionMatch) {
|
||||||
const newValue = this.formElements
|
const diff = modelValue.filter(x => !oldModelValue.includes(x));
|
||||||
.filter(option => diff.includes(option.choiceValue))
|
const newValue = this.formElements
|
||||||
.map(option => this._getTextboxValueFromOption(option))
|
.filter(option => diff.includes(option.choiceValue))
|
||||||
.join(' ');
|
.map(option => this._getTextboxValueFromOption(option))
|
||||||
this._setTextboxValue(newValue); // or last selected value?
|
.join(' ');
|
||||||
|
this._setTextboxValue(newValue); // or last selected value?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -72,15 +72,18 @@ export async function mimicUserTypingAdvanced(el, values) {
|
||||||
const selectionEnd = _inputNode.selectionEnd || 0;
|
const selectionEnd = _inputNode.selectionEnd || 0;
|
||||||
const hasSelection = selectionStart !== selectionEnd;
|
const hasSelection = selectionStart !== selectionEnd;
|
||||||
|
|
||||||
if (key === 'Backspace') {
|
if (key === 'Backspace' || key === 'Delete') {
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
_inputNode.value =
|
_inputNode.value =
|
||||||
_inputNode.value.slice(0, selectionStart) + _inputNode.value.slice(selectionEnd);
|
_inputNode.value.slice(0, selectionStart) + _inputNode.value.slice(selectionEnd);
|
||||||
cursorPosition = selectionStart;
|
cursorPosition = selectionStart;
|
||||||
} else if (cursorPosition > 0) {
|
} else if (cursorPosition > 0 && key === 'Backspace') {
|
||||||
_inputNode.value =
|
_inputNode.value =
|
||||||
_inputNode.value.slice(0, cursorPosition - 1) + _inputNode.value.slice(cursorPosition);
|
_inputNode.value.slice(0, cursorPosition - 1) + _inputNode.value.slice(cursorPosition);
|
||||||
cursorPosition -= 1;
|
cursorPosition -= 1;
|
||||||
|
} else if (cursorPosition < _inputNode.value.length && key === 'Delete') {
|
||||||
|
_inputNode.value =
|
||||||
|
_inputNode.value.slice(0, cursorPosition) + _inputNode.value.slice(cursorPosition + 1);
|
||||||
}
|
}
|
||||||
} else if (hasSelection) {
|
} else if (hasSelection) {
|
||||||
_inputNode.value =
|
_inputNode.value =
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { runListboxMixinSuite } from '@lion/ui/listbox-test-suites.js';
|
import { runListboxMixinSuite } from '@lion/ui/listbox-test-suites.js';
|
||||||
import '@lion/ui/define/lion-combobox.js';
|
import '@lion/ui/define/lion-combobox.js';
|
||||||
|
import { runCustomChoiceGroupMixinSuite } from '../../form-core/test-suites/choice-group/CustomChoiceGroupMixin.suite.js';
|
||||||
|
|
||||||
runListboxMixinSuite({ tagString: 'lion-combobox' });
|
runListboxMixinSuite({ tagString: 'lion-combobox' });
|
||||||
|
runCustomChoiceGroupMixinSuite({
|
||||||
|
parentTagString: 'lion-combobox',
|
||||||
|
childTagString: 'lion-option',
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -395,29 +395,6 @@ describe('lion-combobox', () => {
|
||||||
expect(el.formElements[0].checked).to.be.false;
|
expect(el.formElements[0].checked).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('multiple choice and requireOptionMatch is false do not work together yet', async () => {
|
|
||||||
const errorMessage = `multipleChoice and requireOptionMatch=false can't be used at the same time (yet).`;
|
|
||||||
let error;
|
|
||||||
const el = /** @type {LionCombobox} */ (
|
|
||||||
await fixture(html`
|
|
||||||
<lion-combobox name="foo" multiple-choice>
|
|
||||||
<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>
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
el.requireOptionMatch = false;
|
|
||||||
await el.updateComplete;
|
|
||||||
} catch (err) {
|
|
||||||
error = err;
|
|
||||||
}
|
|
||||||
expect(error).to.be.instanceOf(Error);
|
|
||||||
expect(/** @type {Error} */ (error).message).to.equal(errorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears modelValue and textbox value on clear()', async () => {
|
it('clears modelValue and textbox value on clear()', async () => {
|
||||||
const el = /** @type {LionCombobox} */ (
|
const el = /** @type {LionCombobox} */ (
|
||||||
await fixture(html`
|
await fixture(html`
|
||||||
|
|
@ -591,6 +568,33 @@ describe('lion-combobox', () => {
|
||||||
expect(el.showsFeedbackFor).to.include('error', 'showsFeedbackFor');
|
expect(el.showsFeedbackFor).to.include('error', 'showsFeedbackFor');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores empty string modelValue inputs', async () => {
|
||||||
|
const el = /** @type {LionCombobox} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<lion-combobox name="foo" multiple-choice autocomplete="none">
|
||||||
|
<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>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
el.requireOptionMatch = false;
|
||||||
|
await el.updateComplete;
|
||||||
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
|
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.modelValue).to.eql([]);
|
||||||
|
|
||||||
|
mimicUserTyping(el, ' ');
|
||||||
|
await el.updateComplete;
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.modelValue).to.eql([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows a value outside of the option list when requireOptionMatch is false', async () => {
|
it('allows a value outside of the option list when requireOptionMatch is false', async () => {
|
||||||
const el = /** @type {LionCombobox} */ (
|
const el = /** @type {LionCombobox} */ (
|
||||||
await fixture(html`
|
await fixture(html`
|
||||||
|
|
@ -615,6 +619,35 @@ describe('lion-combobox', () => {
|
||||||
expect(_inputNode.value).to.equal('Foo');
|
expect(_inputNode.value).to.equal('Foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("doesn't select any similar options after using delete when requireOptionMatch is false", async () => {
|
||||||
|
const el = /** @type {LionCombobox} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<lion-combobox name="foo" .validators=${[new Required()]}>
|
||||||
|
<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>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
el.requireOptionMatch = false;
|
||||||
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
|
|
||||||
|
mimicUserTyping(el, 'Art');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
await mimicUserTypingAdvanced(el, ['Delete']);
|
||||||
|
await el.updateComplete;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.checkedIndex).to.equal(-1);
|
||||||
|
expect(el.modelValue).to.equal('Art');
|
||||||
|
expect(_inputNode.value).to.equal('Art');
|
||||||
|
});
|
||||||
|
|
||||||
it("when removing a letter it won't select the option", async () => {
|
it("when removing a letter it won't select the option", async () => {
|
||||||
// We don't autocomplete when characters are removed
|
// We don't autocomplete when characters are removed
|
||||||
const el = /** @type {LionCombobox} */ (
|
const el = /** @type {LionCombobox} */ (
|
||||||
|
|
@ -667,6 +700,173 @@ describe('lion-combobox', () => {
|
||||||
expect(el.modelValue).to.equal('Foo');
|
expect(el.modelValue).to.equal('Foo');
|
||||||
expect(el.formElements[0].checked).to.be.false;
|
expect(el.formElements[0].checked).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows custom selections when multi-choice when requireOptionMatch is false', async () => {
|
||||||
|
const el = /** @type {LionCombobox} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<lion-combobox
|
||||||
|
name="foo"
|
||||||
|
multiple-choice
|
||||||
|
.validators=${[new Required()]}
|
||||||
|
autocomplete="none"
|
||||||
|
>
|
||||||
|
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
||||||
|
<lion-option checked .choiceValue="${'Chard'}">Chard</lion-option>
|
||||||
|
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
|
||||||
|
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
|
||||||
|
</lion-combobox>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
el.requireOptionMatch = false;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
|
expect(el.modelValue).to.eql(['Chard']);
|
||||||
|
expect(el.checkedIndex).to.eql([1]);
|
||||||
|
|
||||||
|
mimicUserTyping(el, 'Foo');
|
||||||
|
await el.updateComplete;
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['Chard', 'Foo']);
|
||||||
|
expect(el.checkedIndex).to.eql([1]);
|
||||||
|
|
||||||
|
mimicUserTyping(el, 'Bar');
|
||||||
|
await el.updateComplete;
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows manyu custom selections when multi-choice when requireOptionMatch is false', async () => {
|
||||||
|
const el = /** @type {LionCombobox} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<lion-combobox
|
||||||
|
name="foo"
|
||||||
|
multiple-choice
|
||||||
|
.validators=${[new Required()]}
|
||||||
|
autocomplete="none"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
el.requireOptionMatch = false;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
|
|
||||||
|
mimicUserTyping(el, 'Foo');
|
||||||
|
await el.updateComplete;
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['Foo']);
|
||||||
|
expect(el.checkedIndex).to.eql([]);
|
||||||
|
|
||||||
|
mimicUserTyping(el, 'Bar');
|
||||||
|
await el.updateComplete;
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['Foo', 'Bar']);
|
||||||
|
expect(el.checkedIndex).to.eql([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows new options when multi-choice when requireOptionMatch=false and autocomplete="both", without selecting similar values', async () => {
|
||||||
|
const el = /** @type {LionCombobox} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<lion-combobox
|
||||||
|
name="foo"
|
||||||
|
multiple-choice
|
||||||
|
.requireOptionMatch=${false}
|
||||||
|
autocomplete="both"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
|
|
||||||
|
mimicUserTyping(el, 'Artist');
|
||||||
|
await el.updateComplete;
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['Artist']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows new options when multi-choice when requireOptionMatch=false and autocomplete="both", when deleting autocomplete values using Backspace', async () => {
|
||||||
|
const el = /** @type {LionCombobox} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<lion-combobox
|
||||||
|
name="foo"
|
||||||
|
multiple-choice
|
||||||
|
.requireOptionMatch=${false}
|
||||||
|
autocomplete="both"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
|
mimicUserTyping(el, 'Art');
|
||||||
|
await el.updateComplete;
|
||||||
|
await mimicUserTypingAdvanced(el, ['Backspace']);
|
||||||
|
await el.updateComplete;
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['Art']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows new custom options when multi-choice when requireOptionMatch=false and autocomplete="both", when deleting autocompleted values using Delete', async () => {
|
||||||
|
const el = /** @type {LionCombobox} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<lion-combobox
|
||||||
|
name="foo"
|
||||||
|
multiple-choice
|
||||||
|
.requireOptionMatch=${false}
|
||||||
|
autocomplete="both"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const { _inputNode } = getComboboxMembers(el);
|
||||||
|
el.modelValue = [];
|
||||||
|
|
||||||
|
mimicUserTyping(el, 'Art');
|
||||||
|
await el.updateComplete;
|
||||||
|
await mimicUserTypingAdvanced(el, ['Delete']);
|
||||||
|
await el.updateComplete;
|
||||||
|
mimicKeyPress(_inputNode, 'Enter');
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.modelValue).to.eql(['Art']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Overlay visibility', () => {
|
describe('Overlay visibility', () => {
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ const ChoiceGroupMixinImplementation = superclass =>
|
||||||
/** @param {import('lit').PropertyValues} changedProperties */
|
/** @param {import('lit').PropertyValues} changedProperties */
|
||||||
updated(changedProperties) {
|
updated(changedProperties) {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
||||||
if (changedProperties.has('name') && this.name !== changedProperties.get('name')) {
|
if (changedProperties.has('name') && this.name !== changedProperties.get('name')) {
|
||||||
this.formElements.forEach(child => {
|
this.formElements.forEach(child => {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { dedupeMixin } from '@open-wc/dedupe-mixin';
|
||||||
|
import { ChoiceGroupMixin } from './ChoiceGroupMixin.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('../../types/choice-group/CustomChoiceGroupMixinTypes.js').CustomChoiceGroupMixin} CustomChoiceGroupMixin
|
||||||
|
* @typedef {import('../../types/choice-group/CustomChoiceGroupMixinTypes.js').CustomChoiceGroupHost} CustomChoiceGroupHost
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any|any[]} value
|
||||||
|
* @returns {any[]}
|
||||||
|
*/
|
||||||
|
function ensureArray(value) {
|
||||||
|
return Array.isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends the ChoiceGroupMixin to add optional support for custom user choices without altering the initial choice list.
|
||||||
|
*
|
||||||
|
* @type {CustomChoiceGroupMixin}
|
||||||
|
* @param {import('@open-wc/dedupe-mixin').Constructor<import('lit').LitElement>} superclass
|
||||||
|
*/
|
||||||
|
const CustomChoiceGroupMixinImplementation = superclass =>
|
||||||
|
// @ts-ignore https://github.com/microsoft/TypeScript/issues/36821#issuecomment-588375051
|
||||||
|
class CustomChoiceGroupMixin extends ChoiceGroupMixin(superclass) {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
allowCustomChoice: {
|
||||||
|
type: Boolean,
|
||||||
|
attribute: 'allow-custom-choice',
|
||||||
|
},
|
||||||
|
modelValue: { type: Object },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
get modelValue() {
|
||||||
|
return this.__getChoicesFrom(super.modelValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
set modelValue(value) {
|
||||||
|
super.modelValue = value;
|
||||||
|
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
// @ts-ignore
|
||||||
|
this._customChoices = new Set();
|
||||||
|
} else if (this.allowCustomChoice) {
|
||||||
|
const old = this.modelValue;
|
||||||
|
// @ts-ignore
|
||||||
|
this._customChoices = new Set(ensureArray(value));
|
||||||
|
this.requestUpdate('modelValue', old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
get formattedValue() {
|
||||||
|
return this.__getChoicesFrom(super.formattedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
set formattedValue(value) {
|
||||||
|
super.formattedValue = value;
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
this._customChoices = new Set();
|
||||||
|
} else if (this.allowCustomChoice) {
|
||||||
|
const old = this.modelValue;
|
||||||
|
// Convert formattedValue to modelValue to store as custom choices, or fall back to the input value
|
||||||
|
this._customChoices = new Set(
|
||||||
|
ensureArray(value).map(
|
||||||
|
val => this.formElements.find(el => el.formattedValue === val)?.modelValue || val,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.requestUpdate('modelValue', old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
get serializedValue() {
|
||||||
|
return this.__getChoicesFrom(super.serializedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
set serializedValue(value) {
|
||||||
|
super.serializedValue = value;
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
this._customChoices = new Set();
|
||||||
|
} else if (this.allowCustomChoice) {
|
||||||
|
const old = this.modelValue;
|
||||||
|
// Convert serializedValue to modelValue to store as custom choices, or fall back to the input value
|
||||||
|
this._customChoices = new Set(
|
||||||
|
ensureArray(value).map(
|
||||||
|
val => this.formElements.find(el => el.serializedValue === val)?.modelValue || val,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.requestUpdate('modelValue', old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom elements are all missing elements that have no corresponding element, independent if enabled or not.
|
||||||
|
*/
|
||||||
|
// @ts-ignore
|
||||||
|
get customChoices() {
|
||||||
|
if (!this.allowCustomChoice) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const elems = this._getCheckedElements();
|
||||||
|
|
||||||
|
return Array.from(this._customChoices).filter(
|
||||||
|
choice => !elems.some(elem => elem.choiceValue === choice),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.allowCustomChoice = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Set<unknown>}
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
this._customChoices = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
// @ts-ignore
|
||||||
|
__getChoicesFrom(input) {
|
||||||
|
const values = input;
|
||||||
|
if (!this.allowCustomChoice) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.multipleChoice) {
|
||||||
|
return [...ensureArray(values), ...this.customChoices];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values === '') {
|
||||||
|
return this._customChoices.values().next().value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
_isEmpty() {
|
||||||
|
return super._isEmpty() && this._customChoices.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this._customChoices = new Set();
|
||||||
|
super.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|string[]} value
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
parser(value) {
|
||||||
|
if (this.allowCustomChoice && Array.isArray(value)) {
|
||||||
|
return value.filter(v => v.trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomChoiceGroupMixin = dedupeMixin(CustomChoiceGroupMixinImplementation);
|
||||||
|
|
@ -45,7 +45,7 @@ export class Required extends Validator {
|
||||||
/**
|
/**
|
||||||
* @param {FormControlHost & HTMLElement} formControl
|
* @param {FormControlHost & HTMLElement} formControl
|
||||||
*/
|
*/
|
||||||
// @ts-ignore [allow-protected] we are allowed to know FormControl protcected props in form-core
|
// @ts-ignore [allow-protected] we are allowed to know FormControl protected props in form-core
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
onFormControlConnect({ _inputNode: inputNode }) {
|
onFormControlConnect({ _inputNode: inputNode }) {
|
||||||
if (inputNode) {
|
if (inputNode) {
|
||||||
|
|
@ -61,7 +61,7 @@ export class Required extends Validator {
|
||||||
/**
|
/**
|
||||||
* @param {FormControlHost & HTMLElement} formControl
|
* @param {FormControlHost & HTMLElement} formControl
|
||||||
*/
|
*/
|
||||||
// @ts-ignore [allow-protected] we are allowed to know FormControl protcected props in form-core
|
// @ts-ignore [allow-protected] we are allowed to know FormControl protected props in form-core
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
onFormControlDisconnect({ _inputNode: inputNode }) {
|
onFormControlDisconnect({ _inputNode: inputNode }) {
|
||||||
if (inputNode) {
|
if (inputNode) {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export function runChoiceGroupMixinSuite({ parentTagString, childTagString, choi
|
||||||
</${parentTag}>
|
</${parentTag}>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(el.modelValue).to.equal('female');
|
expect(el.modelValue).to.equal('female');
|
||||||
el.formElements[0].checked = true;
|
el.formElements[0].checked = true;
|
||||||
expect(el.modelValue).to.equal('male');
|
expect(el.modelValue).to.equal('male');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
import '@lion/ui/define/lion-fieldset.js';
|
||||||
|
import '@lion/ui/define/lion-checkbox-group.js';
|
||||||
|
import '@lion/ui/define/lion-checkbox.js';
|
||||||
|
import { expect, fixture, fixtureSync, html, unsafeStatic } from '@open-wc/testing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @typedef {import('../../test/choice-group/CustomChoiceGroupMixin.test.js').CustomChoiceGroup} CustomChoiceGroup
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ parentTagString?:string, childTagString?: string, choiceType?: string}} config
|
||||||
|
*/
|
||||||
|
export function runCustomChoiceGroupMixinSuite({
|
||||||
|
parentTagString,
|
||||||
|
childTagString,
|
||||||
|
choiceType,
|
||||||
|
} = {}) {
|
||||||
|
const cfg = {
|
||||||
|
parentTagString: parentTagString || 'custom-choice-input-group',
|
||||||
|
childTagString: childTagString || 'custom-choice-input',
|
||||||
|
choiceType: choiceType || 'single',
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentTag = unsafeStatic(cfg.parentTagString);
|
||||||
|
const childTag = unsafeStatic(cfg.childTagString);
|
||||||
|
|
||||||
|
describe(`CustomChoiceGroupMixin: ${cfg.parentTagString}`, () => {
|
||||||
|
if (cfg.choiceType === 'single') {
|
||||||
|
it('has a single modelValue representing a custom value', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} checked .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await el.registrationComplete;
|
||||||
|
|
||||||
|
expect(el.modelValue).to.equal('female');
|
||||||
|
el.modelValue = 'male';
|
||||||
|
expect(el.modelValue).to.equal('male');
|
||||||
|
|
||||||
|
el.modelValue = 'other';
|
||||||
|
expect(el.modelValue).to.equal('other');
|
||||||
|
|
||||||
|
expect(el.formElements[0].checked).to.be.false;
|
||||||
|
expect(el.formElements[1].checked).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a single formattedValue representing a custom value', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice name="gender">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
el.modelValue = 'other';
|
||||||
|
expect(el.formattedValue).to.equal('other');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('can set initial custom modelValue on creation', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice name="gender[]" .modelValue=${'other'}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cfg.choiceType === 'single') {
|
||||||
|
expect(el.modelValue).to.equal('other');
|
||||||
|
} else {
|
||||||
|
expect(el.modelValue).to.deep.equal(['other']);
|
||||||
|
}
|
||||||
|
expect(el.formElements[0].checked).to.be.false;
|
||||||
|
expect(el.formElements[1].checked).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can set initial custom serializedValue on creation', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice name="gender[]" .serializedValue=${'other'}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cfg.choiceType === 'single') {
|
||||||
|
expect(el.serializedValue).to.equal('other');
|
||||||
|
} else {
|
||||||
|
expect(el.serializedValue).to.deep.equal(['other']);
|
||||||
|
}
|
||||||
|
expect(el.formElements[0].checked).to.be.false;
|
||||||
|
expect(el.formElements[1].checked).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can set initial custom formattedValue on creation', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice name="gender[]" .formattedValue=${'other'}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cfg.choiceType === 'single') {
|
||||||
|
expect(el.formattedValue).to.equal('other');
|
||||||
|
} else {
|
||||||
|
expect(el.formattedValue).to.deep.equal(['other']);
|
||||||
|
}
|
||||||
|
expect(el.formElements[0].checked).to.be.false;
|
||||||
|
expect(el.formElements[1].checked).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly handles custom modelValue being set before registrationComplete', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
fixtureSync(html`
|
||||||
|
<${parentTag} allow-custom-choice name="gender[]" .modelValue=${null}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cfg.choiceType === 'single') {
|
||||||
|
el.modelValue = 'other';
|
||||||
|
await el.registrationComplete;
|
||||||
|
expect(el.modelValue).to.equal('other');
|
||||||
|
} else {
|
||||||
|
el.modelValue = ['other'];
|
||||||
|
await el.registrationComplete;
|
||||||
|
expect(el.modelValue).to.deep.equal(['other']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly handles custom serializedValue being set before registrationComplete', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
fixtureSync(html`
|
||||||
|
<${parentTag} allow-custom-choice name="gender[]" .serializedValue=${null}>
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cfg.choiceType === 'single') {
|
||||||
|
// @ts-expect-error
|
||||||
|
el.serializedValue = 'other';
|
||||||
|
await el.registrationComplete;
|
||||||
|
expect(el.serializedValue).to.equal('other');
|
||||||
|
} else {
|
||||||
|
// @ts-expect-error
|
||||||
|
el.serializedValue = ['other'];
|
||||||
|
await el.registrationComplete;
|
||||||
|
expect(el.serializedValue).to.deep.equal(['other']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be cleared, even when a custom value is selected', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'}></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
if (cfg.choiceType === 'single') {
|
||||||
|
el.modelValue = 'other';
|
||||||
|
} else {
|
||||||
|
el.modelValue = ['other'];
|
||||||
|
}
|
||||||
|
|
||||||
|
el.clear();
|
||||||
|
|
||||||
|
if (cfg.choiceType === 'single') {
|
||||||
|
expect(el.serializedValue).to.deep.equal('');
|
||||||
|
} else {
|
||||||
|
expect(el.serializedValue).to.deep.equal([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multipleChoice', () => {
|
||||||
|
it('has a single modelValue representing all currently checked values, including custom values', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['female']);
|
||||||
|
|
||||||
|
el.modelValue = ['female', 'male'];
|
||||||
|
expect(el.modelValue).to.eql(['male', 'female']);
|
||||||
|
|
||||||
|
el.modelValue = ['female', 'male', 'other'];
|
||||||
|
expect(el.modelValue).to.eql(['male', 'female', 'other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a single serializedValue representing all currently checked values, including custom values', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(el.serializedValue).to.eql(['female']);
|
||||||
|
|
||||||
|
el.modelValue = ['female', 'male', 'other'];
|
||||||
|
expect(el.serializedValue).to.eql(['male', 'female', 'other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a single formattedValue representing all currently checked values', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'}></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(el.formattedValue).to.eql(['female']);
|
||||||
|
|
||||||
|
el.modelValue = ['female', 'male', 'other'];
|
||||||
|
expect(el.formattedValue).to.eql(['male', 'female', 'other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unchecks non-matching checkboxes when setting the modelValue', async () => {
|
||||||
|
const el = /** @type {CustomChoiceGroup} */ (
|
||||||
|
await fixture(html`
|
||||||
|
<${parentTag} allow-custom-choice multiple-choice name="gender[]">
|
||||||
|
<${childTag} .choiceValue=${'male'} checked></${childTag}>
|
||||||
|
<${childTag} .choiceValue=${'female'} checked></${childTag}>
|
||||||
|
</${parentTag}>
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(el.modelValue).to.eql(['male', 'female']);
|
||||||
|
expect(el.formElements[0].checked).to.be.true;
|
||||||
|
expect(el.formElements[1].checked).to.be.true;
|
||||||
|
|
||||||
|
el.modelValue = ['other'];
|
||||||
|
expect(el.formElements[0].checked).to.be.false;
|
||||||
|
expect(el.formElements[1].checked).to.be.false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { runChoiceGroupMixinSuite } from '@lion/ui/form-core-test-suites.js';
|
||||||
|
import { LitElement } from 'lit';
|
||||||
|
import '@lion/ui/define/lion-fieldset.js';
|
||||||
|
import '@lion/ui/define/lion-checkbox-group.js';
|
||||||
|
import '@lion/ui/define/lion-checkbox.js';
|
||||||
|
import { FormGroupMixin, ChoiceInputMixin } from '@lion/ui/form-core.js';
|
||||||
|
import { LionInput } from '@lion/ui/input.js';
|
||||||
|
import { CustomChoiceGroupMixin } from '../../src/choice-group/CustomChoiceGroupMixin.js';
|
||||||
|
|
||||||
|
class CustomChoiceGroup extends CustomChoiceGroupMixin(FormGroupMixin(LitElement)) {}
|
||||||
|
customElements.define('custom-choice-input-group', CustomChoiceGroup);
|
||||||
|
|
||||||
|
class ChoiceInput extends ChoiceInputMixin(LionInput) {}
|
||||||
|
customElements.define('custom-choice-input', ChoiceInput);
|
||||||
|
|
||||||
|
class CustomChoiceGroupAllowCustom extends CustomChoiceGroupMixin(FormGroupMixin(LitElement)) {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.allowCustomChoice = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('allow-custom-choice-input-group', CustomChoiceGroupAllowCustom);
|
||||||
|
|
||||||
|
class MultipleCustomChoiceGroup extends CustomChoiceGroupMixin(FormGroupMixin(LitElement)) {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.multipleChoice = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('multiple-custom-choice-input-group', MultipleCustomChoiceGroup);
|
||||||
|
|
||||||
|
class MultipleCustomChoiceGroupAllowCustom extends CustomChoiceGroupMixin(
|
||||||
|
FormGroupMixin(LitElement),
|
||||||
|
) {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.multipleChoice = true;
|
||||||
|
this.allowCustomChoice = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define(
|
||||||
|
'multiple-allow-custom-choice-input-group',
|
||||||
|
MultipleCustomChoiceGroupAllowCustom,
|
||||||
|
);
|
||||||
|
|
||||||
|
runChoiceGroupMixinSuite({ parentTagString: 'custom-choice-input-group' });
|
||||||
|
runChoiceGroupMixinSuite({
|
||||||
|
parentTagString: 'multiple-custom-choice-input-group',
|
||||||
|
choiceType: 'multiple',
|
||||||
|
});
|
||||||
|
runChoiceGroupMixinSuite({ parentTagString: 'allow-custom-choice-input-group' });
|
||||||
|
runChoiceGroupMixinSuite({
|
||||||
|
parentTagString: 'multiple-allow-custom-choice-input-group',
|
||||||
|
choiceType: 'multiple',
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { ChoiceInputMixin, FormGroupMixin } from '@lion/ui/form-core.js';
|
||||||
|
import { LionInput } from '@lion/ui/input.js';
|
||||||
|
import { LitElement } from 'lit';
|
||||||
|
import '@lion/ui/define/lion-fieldset.js';
|
||||||
|
import '@lion/ui/define/lion-checkbox-group.js';
|
||||||
|
import '@lion/ui/define/lion-checkbox.js';
|
||||||
|
import { CustomChoiceGroupMixin } from '../../src/choice-group/CustomChoiceGroupMixin.js';
|
||||||
|
import { runCustomChoiceGroupMixinSuite } from '../../test-suites/choice-group/CustomChoiceGroupMixin.suite.js';
|
||||||
|
|
||||||
|
export class CustomChoiceGroup extends CustomChoiceGroupMixin(FormGroupMixin(LitElement)) {}
|
||||||
|
customElements.define('custom-choice-input-group', CustomChoiceGroup);
|
||||||
|
|
||||||
|
class ChoiceInput extends ChoiceInputMixin(LionInput) {}
|
||||||
|
customElements.define('custom-choice-input', ChoiceInput);
|
||||||
|
|
||||||
|
runCustomChoiceGroupMixinSuite();
|
||||||
|
|
@ -25,7 +25,7 @@ export declare class ChoiceGroupHost {
|
||||||
filterFn?: (el: FormControl, property?: string) => boolean,
|
filterFn?: (el: FormControl, property?: string) => boolean,
|
||||||
): void;
|
): void;
|
||||||
protected _throwWhenInvalidChildModelValue(child: FormControlHost): void;
|
protected _throwWhenInvalidChildModelValue(child: FormControlHost): void;
|
||||||
protected _isEmpty(): void;
|
protected _isEmpty(): boolean;
|
||||||
protected _checkSingleChoiceElements(ev: Event): void;
|
protected _checkSingleChoiceElements(ev: Event): void;
|
||||||
protected _getCheckedElements(): ChoiceInputHost[];
|
protected _getCheckedElements(): ChoiceInputHost[];
|
||||||
protected _setCheckedElements(value: any, check: boolean): void;
|
protected _setCheckedElements(value: any, check: boolean): void;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Constructor } from '@open-wc/dedupe-mixin';
|
||||||
|
import { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import { ChoiceGroupHost } from './ChoiceGroupMixinTypes.js';
|
||||||
|
|
||||||
|
export declare class CustomChoiceGroupHost {
|
||||||
|
allowCustomChoice: boolean;
|
||||||
|
get modelValue(): any;
|
||||||
|
set modelValue(value: any);
|
||||||
|
get serializedValue(): string;
|
||||||
|
set serializedValue(value: string);
|
||||||
|
get formattedValue(): string;
|
||||||
|
set formattedValue(value: string);
|
||||||
|
|
||||||
|
clear(): void;
|
||||||
|
parser(value: string | string[]): string | string[];
|
||||||
|
|
||||||
|
protected _isEmpty(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare function CustomChoiceGroupImplementation<T extends Constructor<LitElement>>(
|
||||||
|
superclass: T,
|
||||||
|
): T &
|
||||||
|
Constructor<CustomChoiceGroupHost> &
|
||||||
|
Pick<typeof CustomChoiceGroupHost, keyof typeof CustomChoiceGroupHost> &
|
||||||
|
Constructor<ChoiceGroupHost> &
|
||||||
|
Pick<typeof ChoiceGroupHost, keyof typeof ChoiceGroupHost> &
|
||||||
|
Pick<typeof LitElement, keyof typeof LitElement>;
|
||||||
|
|
||||||
|
export type CustomChoiceGroupMixin = typeof CustomChoiceGroupImplementation;
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './ChoiceInputMixinTypes.js';
|
export * from './ChoiceInputMixinTypes.js';
|
||||||
export * from './ChoiceGroupMixinTypes.js';
|
export * from './ChoiceGroupMixinTypes.js';
|
||||||
|
export * from './CustomChoiceGroupMixinTypes.js';
|
||||||
|
|
|
||||||
|
|
@ -196,22 +196,6 @@ const ListboxMixinImplementation = superclass =>
|
||||||
return this._listboxNode;
|
return this._listboxNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @override ChoiceGroupMixin
|
|
||||||
*/
|
|
||||||
get serializedValue() {
|
|
||||||
return this.modelValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
|
|
||||||
// If you override one, gotta override the other, they go in pairs.
|
|
||||||
/**
|
|
||||||
* @override ChoiceGroupMixin
|
|
||||||
*/
|
|
||||||
set serializedValue(value) {
|
|
||||||
super.serializedValue = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeIndex() {
|
get activeIndex() {
|
||||||
return this.formElements.findIndex(el => el.active === true);
|
return this.formElements.findIndex(el => el.active === true);
|
||||||
}
|
}
|
||||||
|
|
@ -473,6 +457,7 @@ const ListboxMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
super.clear();
|
||||||
this.setCheckedIndex(-1);
|
this.setCheckedIndex(-1);
|
||||||
this.resetInteractionState();
|
this.resetInteractionState();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ export function runListboxMixinSuite(customConfig = {}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requests update for modelValue when checkedIndex changes', async () => {
|
it('requests update for modelValue when checkedIndex changes', async () => {
|
||||||
const el = await fixture(html`
|
const el = /** @type {LionListbox} */ await fixture(html`
|
||||||
<${tag} name="gender" .modelValue=${'other'}>
|
<${tag} name="gender" .modelValue=${'other'}>
|
||||||
<${optionTag} .choiceValue=${'male'}></${optionTag}>
|
<${optionTag} .choiceValue=${'male'}></${optionTag}>
|
||||||
<${optionTag} .choiceValue=${'female'}></${optionTag}>
|
<${optionTag} .choiceValue=${'female'}></${optionTag}>
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,8 @@ export { FormRegistrarHost } from '../../components/form-core/types/registration
|
||||||
export { ElementWithParentFormGroup } from '../../components/form-core/types/registration/FormRegistrarMixinTypes.js';
|
export { ElementWithParentFormGroup } from '../../components/form-core/types/registration/FormRegistrarMixinTypes.js';
|
||||||
export { FormRegistrarPortalHost } from '../../components/form-core/types/registration/FormRegistrarPortalMixinTypes.js';
|
export { FormRegistrarPortalHost } from '../../components/form-core/types/registration/FormRegistrarPortalMixinTypes.js';
|
||||||
export { SyncUpdatableHost } from '../../components/form-core/types/utils/SyncUpdatableMixinTypes.js';
|
export { SyncUpdatableHost } from '../../components/form-core/types/utils/SyncUpdatableMixinTypes.js';
|
||||||
export { ValidateHost, ValidationType, FeedbackMessage } from '../../components/form-core/types/validate/ValidateMixinTypes.js';
|
export {
|
||||||
|
ValidateHost,
|
||||||
|
ValidationType,
|
||||||
|
FeedbackMessage,
|
||||||
|
} from '../../components/form-core/types/validate/ValidateMixinTypes.js';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue