diff --git a/.changeset/soft-eagles-reply.md b/.changeset/soft-eagles-reply.md new file mode 100644 index 000000000..fe9115ea5 --- /dev/null +++ b/.changeset/soft-eagles-reply.md @@ -0,0 +1,9 @@ +--- +'@lion/ui': patch +--- + +[combobox] Multiple improvements: + +- Allow textbox values to be entered that do not match a listbox option, via `requireOptionMatch` flag. +- Added an `MatchesOption` validator to check if the value is matching an option. +- Exports combobox test helpers diff --git a/docs/components/combobox/overview.md b/docs/components/combobox/overview.md index 957b21646..51f6fd4b3 100644 --- a/docs/components/combobox/overview.md +++ b/docs/components/combobox/overview.md @@ -37,7 +37,17 @@ export const main = () => html` ## Features -> tbd +The combobox has many configurable properties to fine-tune its behaviour: + +- **Multiple choice** - Allows multiselection of options. +- **requireOptionMatch** + - **true** (default) - The listbox is leading, the textbox is a helping aid to quickly select an option/options. Unmatching input values become Unparseable, with the `MatchesOption` set as a default validator. + - **false** - The textbox is leading, with the listbox as an aid to supply suggestions, e.g. a search input. +- **Autocomplete** - When the autocompletion will happen: `none`, `list`, `inline` and `both`. +- **Matchmode** - Which part of the value should match: `begin` and `all`. +- **Show all on empty** - Shows the options list on empty. +- **Selection follows focus** - When false the active/focused and checked/selected values will be kept track of independently. +- **Rotate keyboard Navigation** - When false it won't rotate the navigation. ## Installation diff --git a/docs/components/combobox/use-cases.md b/docs/components/combobox/use-cases.md index 9dbc6a548..abda92a4a 100644 --- a/docs/components/combobox/use-cases.md +++ b/docs/components/combobox/use-cases.md @@ -16,11 +16,30 @@ availability of the popup. import { LitElement, html, repeat } from '@mdjs/mdjs-preview'; import { listboxData, listboxComplexData } from '../listbox/src/listboxData.js'; import { LionCombobox } from '@lion/ui/combobox.js'; +import { Required } from '@lion/ui/form-core.js'; import '@lion/ui/define/lion-combobox.js'; import '@lion/ui/define/lion-option.js'; import './src/demo-selection-display.js'; import { lazyRender } from './src/lazyRender.js'; import levenshtein from './src/levenshtein.js'; +import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js'; +loadDefaultFeedbackMessages(); +``` + +## Require option match + +By default `requireOptionMatch` is set to true, which means that the listbox is leading. The textbox is a helping aid to quickly select an option/options. Unmatching input values become Unparseable, with the `MatchesOption` set as a default validator. + +When `requireOptionMatch` is set to false the textbox is leading, with the listbox as an aid to supply suggestions, e.g. a search input. This means that all input values are allowed. + +```js preview-story +export const optionMatch = () => html` + + ${lazyRender( + listboxData.map(entry => html` ${entry} `), + )} + +`; ``` ## Autocomplete @@ -230,6 +249,22 @@ export const multipleChoice = () => html` `; ``` +## Validation + +The combobox works with a `Required` validator to check if it is empty. + +By default the a check is made which makes sure the value matches an option. This only works if `requireOptionMatch` is set to true. + +```js preview-story +export const validation = () => html` + + ${lazyRender( + listboxData.map(entry => html` ${entry} `), + )} + +`; +``` + ## Invoker button ```js preview-story diff --git a/packages/ui/components/combobox/src/LionCombobox.js b/packages/ui/components/combobox/src/LionCombobox.js index c79b2045d..80412f46e 100644 --- a/packages/ui/components/combobox/src/LionCombobox.js +++ b/packages/ui/components/combobox/src/LionCombobox.js @@ -1,9 +1,11 @@ import { browserDetection } from '@lion/ui/core.js'; +import { Unparseable } from '@lion/ui/form-core.js'; import { LionListbox } from '@lion/ui/listbox.js'; import { LocalizeMixin } from '@lion/ui/localize-no-side-effects.js'; import { OverlayMixin, withDropdownConfig } from '@lion/ui/overlays.js'; import { css, html } from 'lit'; import { makeMatchingTextBold, unmakeMatchingTextBold } from './utils/makeMatchingTextBold.js'; +import { MatchesOption } from './validators.js'; const matchA11ySpanReverseFns = new WeakMap(); @@ -12,12 +14,12 @@ const matchA11ySpanReverseFns = new WeakMap(); // on Listbox or ListNavigationWithActiveDescendantMixin /** - * @typedef {import('../../listbox/src/LionOption.js').LionOption} LionOption - * @typedef {import('../../listbox/src/LionOptions.js').LionOptions} LionOptions - * @typedef {import('../../overlays/types/OverlayConfig.js').OverlayConfig} OverlayConfig - * @typedef {import('../../core/types/SlotMixinTypes.js').SlotsMap} SlotsMap - * @typedef {import('../../form-core/types/choice-group/ChoiceInputMixinTypes.js').ChoiceInputHost} ChoiceInputHost - * @typedef {import('../../form-core/types/FormControlMixinTypes.js').FormControlHost} FormControlHost + * @typedef {import('@lion/ui/listbox.js').LionOption} LionOption + * @typedef {import('@lion/ui/listbox.js').LionOptions} LionOptions + * @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig + * @typedef {import('@lion/ui/types/core.js').SlotsMap} SlotsMap + * @typedef {import('@lion/ui/types/form-core.js').ChoiceInputHost} ChoiceInputHost + * @typedef {import('@lion/ui/types/form-core.js').FormControlHost} FormControlHost * @typedef {import('../types/SelectionDisplay.js').SelectionDisplay} SelectionDisplay */ @@ -38,6 +40,9 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { type: Boolean, attribute: 'show-all-on-empty', }, + requireOptionMatch: { + type: Boolean, + }, __shouldAutocompleteNextUpdate: Boolean, }; } @@ -141,6 +146,60 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { ]; } + /** + * @override ChoiceGroupMixin + */ + // @ts-ignore + get modelValue() { + const choiceGroupModelValue = super.modelValue; + if (choiceGroupModelValue !== '') { + return choiceGroupModelValue; + } + // Since the FormatMixin can't be applied to a [FormGroup](https://github.com/ing-bank/lion/blob/master/packages/ui/components/form-core/src/form-group/FormGroupMixin.js) + // atm, we treat it in a way analogue to InteractionStateMixin (basically same apis, w/o Mixin applied). + // Hence, modelValue has no reactivity by default and we need to call parser manually here... + return this.parser(this.value); + } + + // Duplicating from ChoiceGroupMixin, because you cannot independently inherit/override getter + setter. + // If you override one, gotta override the other, they go in pairs. + /** + * @override ChoiceGroupMixin + */ + set modelValue(value) { + super.modelValue = value; + } + + /** + * We define the value getter/setter below as also defined in LionField (via FormatMixin). + * Since FormatMixin is meant for Formgroups/ChoiceGroup it's not applied on Combobox; + * Combobox is somewhat of a hybrid between a ChoiceGroup and LionField, therefore we copy over + * some of the LionField members to align with its interface. + * + * The view value. Will be delegated to `._inputNode.value` + */ + get value() { + return this._inputNode?.value || this.__value || ''; + } + + /** @param {string} value */ + set value(value) { + // if not yet connected to dom can't change the value + if (this._inputNode) { + this._inputNode.value = value; + /** @type {string | undefined} */ + this.__value = undefined; + } else { + this.__value = value; + } + } + + reset() { + super.reset(); + // @ts-ignore _initialModelValue comes from ListboxMixin + this.value = this._initialModelValue; + } + /** * @enhance FormControlMixin - add slot[name=selection-display] * @protected @@ -256,7 +315,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { * @protected */ get _inputNode() { - if (this._ariaVersion === '1.1') { + if (this._ariaVersion === '1.1' && this._comboboxNode) { return /** @type {HTMLInputElement} */ (this._comboboxNode.querySelector('input')); } return /** @type {HTMLInputElement} */ (this._comboboxNode); @@ -327,7 +386,11 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { * By default, the listbox closes on empty, similar to wai-aria example and */ this.showAllOnEmpty = false; - + /** + * If set to false, the value is allowed to not match any of the options. + * We set the default to true for backwards compatibility + */ + this.requireOptionMatch = true; /** * @configure ListboxMixin: the wai-aria pattern and rotate */ @@ -336,7 +399,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { * @configure ListboxMixin: the wai-aria pattern and have selection follow focus */ this.selectionFollowsFocus = true; - + this.defaultValidators.push(new MatchesOption()); /** * For optimal support, we allow aria v1.1 on newer browsers * @type {'1.1'|'1.0'} @@ -422,6 +485,18 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { } } + /** + * Converts viewValue to modelValue + * @param {string} value - viewValue: the formatted value inside + * @returns {*} modelValue + */ + parser(value) { + if (this.requireOptionMatch && this.checkedIndex === -1 && value !== '') { + return new Unparseable(value); + } + return value; + } + /** * When textbox value doesn't match checkedIndex anymore, update accordingly... * @protected @@ -432,7 +507,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { if (!this.multipleChoice && !autoselect && checkedElement) { const textboxValue = this._getTextboxValueFromOption(checkedElement); if (!this._inputNode.value.startsWith(textboxValue)) { - this.checkedIndex = -1; + this.setCheckedIndex(-1); } } } @@ -480,6 +555,15 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { if (typeof this._selectionDisplayNode?.onComboboxElementUpdated === 'function') { 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).", + ); + } + } } /** @@ -557,7 +641,13 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { */ // eslint-disable-next-line class-methods-use-this _getTextboxValueFromOption(option) { - return option.choiceValue; + if (option) { + return option.choiceValue; + } + if (this.modelValue instanceof Unparseable) { + return this.modelValue.viewValue; + } + return this.modelValue; } /** @@ -814,7 +904,8 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { if (isInlineAutoFillCandidate) { const textboxValue = this._getTextboxValueFromOption(option); - const stringValues = typeof textboxValue === 'string' && typeof curValue === 'string'; + const stringValues = + textboxValue && typeof textboxValue === 'string' && typeof curValue === 'string'; const beginsWith = stringValues && textboxValue.toLowerCase().indexOf(curValue.toLowerCase()) === 0; // We only can do proper inline autofilling when the beginning of the word matches @@ -862,7 +953,8 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { // [7]. If no autofill took place, we are left with the previously matched option; correct this if (autoselect && !hasAutoFilled && !this.multipleChoice) { // This means there is no match for checkedIndex - this.checkedIndex = -1; + this.setCheckedIndex(-1); + this.modelValue = this.parser(inputValue); } // [8]. These values will help computing autofill intentions next autocomplete cycle @@ -1090,6 +1182,6 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) { clear() { super.clear(); - this._inputNode.value = ''; + this.value = ''; } } diff --git a/packages/ui/components/combobox/src/validators.js b/packages/ui/components/combobox/src/validators.js new file mode 100644 index 000000000..cde94fa96 --- /dev/null +++ b/packages/ui/components/combobox/src/validators.js @@ -0,0 +1,18 @@ +/* eslint-disable max-classes-per-file */ +import { Unparseable, Validator } from '@lion/ui/form-core.js'; + +export class MatchesOption extends Validator { + static get validatorName() { + return 'MatchesOption'; + } + + /** + * @param {unknown} [value] + * @param {string | undefined} [options] + * @param {{ node: any }} [config] + */ + // eslint-disable-next-line class-methods-use-this + execute(value, options, config) { + return config?.node.modelValue instanceof Unparseable; + } +} diff --git a/packages/ui/components/combobox/test-helpers/combobox-helpers.js b/packages/ui/components/combobox/test-helpers/combobox-helpers.js new file mode 100644 index 000000000..89fc0da67 --- /dev/null +++ b/packages/ui/components/combobox/test-helpers/combobox-helpers.js @@ -0,0 +1,125 @@ +import { getListboxMembers } from '@lion/ui/listbox-test-helpers.js'; + +/** + * @typedef {import('@lion/ui/combobox.js').LionCombobox} LionCombobox + */ + +/** + * @param { LionCombobox } el + */ +export function getComboboxMembers(el) { + const obj = getListboxMembers(el); + return { + ...obj, + ...{ + // @ts-ignore [allow-protected] in test + _invokerNode: el._invokerNode, + // @ts-ignore [allow-protected] in test + _overlayCtrl: el._overlayCtrl, + // @ts-ignore [allow-protected] in test + _comboboxNode: el._comboboxNode, + // @ts-ignore [allow-protected] in test + _inputNode: el._inputNode, + // @ts-ignore [allow-protected] in test + _listboxNode: el._listboxNode, + // @ts-ignore [allow-protected] in test + _selectionDisplayNode: el._selectionDisplayNode, + // @ts-ignore [allow-protected] in test + _activeDescendantOwnerNode: el._activeDescendantOwnerNode, + // @ts-ignore [allow-protected] in test + _ariaVersion: el._ariaVersion, + }, + }; +} + +/** + * @param {LionCombobox} el + * @param {string} value + */ +// TODO: add keys that actually make sense... +export function mimicUserTyping(el, value) { + const { _inputNode } = getComboboxMembers(el); + _inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); + // eslint-disable-next-line no-param-reassign + _inputNode.value = value; + _inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + _inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: value })); + _inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: value })); +} + +/** + * @param {HTMLElement} el + * @param {string} key + */ +export function mimicKeyPress(el, key) { + el.dispatchEvent(new KeyboardEvent('keydown', { key })); + el.dispatchEvent(new KeyboardEvent('keyup', { key })); +} + +/** + * @param {LionCombobox} el + * @param {string[]} values + */ +export async function mimicUserTypingAdvanced(el, values) { + const { _inputNode } = getComboboxMembers(el); + _inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); + + for (const key of values) { + // eslint-disable-next-line no-await-in-loop, no-loop-func + await new Promise(resolve => { + const hasSelection = _inputNode.selectionStart !== _inputNode.selectionEnd; + + if (key === 'Backspace') { + if (hasSelection) { + _inputNode.value = + _inputNode.value.slice( + 0, + _inputNode.selectionStart ? _inputNode.selectionStart : undefined, + ) + + _inputNode.value.slice( + _inputNode.selectionEnd ? _inputNode.selectionEnd : undefined, + _inputNode.value.length, + ); + } else { + _inputNode.value = _inputNode.value.slice(0, -1); + } + } else if (hasSelection) { + _inputNode.value = + _inputNode.value.slice( + 0, + _inputNode.selectionStart ? _inputNode.selectionStart : undefined, + ) + + key + + _inputNode.value.slice( + _inputNode.selectionEnd ? _inputNode.selectionEnd : undefined, + _inputNode.value.length, + ); + } else { + _inputNode.value += key; + } + + mimicKeyPress(_inputNode, key); + _inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + + el.updateComplete.then(() => { + // @ts-ignore + resolve(); + }); + }); + } +} + +/** + * @param {LionCombobox} el + */ +export function getFilteredOptionValues(el) { + const options = el.formElements; + /** + * @param {{ style: { display: string; }; }} option + */ + const filtered = options.filter(option => option.getAttribute('aria-hidden') !== 'true'); + /** + * @param {{ value: any; }} option + */ + return filtered.map(option => option.value); +} diff --git a/packages/ui/components/combobox/test/lion-combobox.test.js b/packages/ui/components/combobox/test/lion-combobox.test.js index ec6391fce..b3e60a6ea 100644 --- a/packages/ui/components/combobox/test/lion-combobox.test.js +++ b/packages/ui/components/combobox/test/lion-combobox.test.js @@ -1,141 +1,25 @@ -import '@lion/ui/define/lion-combobox.js'; -import { LitElement } from 'lit'; +import { + getComboboxMembers, + getFilteredOptionValues, + mimicKeyPress, + mimicUserTyping, + mimicUserTypingAdvanced, +} from '@lion/ui/combobox-test-helpers.js'; +import { LionCombobox } from '@lion/ui/combobox.js'; import { browserDetection } from '@lion/ui/core.js'; -import { Required } from '@lion/ui/form-core.js'; +import '@lion/ui/define/lion-combobox.js'; import '@lion/ui/define/lion-listbox.js'; import '@lion/ui/define/lion-option.js'; -import { getListboxMembers } from '@lion/ui/listbox-test-helpers.js'; +import { Required, Unparseable } from '@lion/ui/form-core.js'; import { defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing'; +import { LitElement } from 'lit'; import sinon from 'sinon'; -import { LionCombobox } from '@lion/ui/combobox.js'; /** * @typedef {import('../types/SelectionDisplay.js').SelectionDisplay} SelectionDisplay - * @typedef {import('../../listbox/types/ListboxMixinTypes.js').ListboxHost} ListboxHost - * @typedef {import('../../form-core/types/FormControlMixinTypes.js').FormControlHost} FormControlHost * @typedef {import('@lion/ui/listbox.js').LionOption} LionOption */ -/** - * @param { LionCombobox } el - */ -function getComboboxMembers(el) { - const obj = getListboxMembers(el); - return { - ...obj, - ...{ - // @ts-ignore [allow-protected] in test - _invokerNode: el._invokerNode, - // @ts-ignore [allow-protected] in test - _overlayCtrl: el._overlayCtrl, - // @ts-ignore [allow-protected] in test - _comboboxNode: el._comboboxNode, - // @ts-ignore [allow-protected] in test - _inputNode: el._inputNode, - // @ts-ignore [allow-protected] in test - _listboxNode: el._listboxNode, - // @ts-ignore [allow-protected] in test - _selectionDisplayNode: el._selectionDisplayNode, - // @ts-ignore [allow-protected] in test - _activeDescendantOwnerNode: el._activeDescendantOwnerNode, - // @ts-ignore [allow-protected] in test - _ariaVersion: el._ariaVersion, - }, - }; -} - -/** - * @param {LionCombobox} el - * @param {string} value - */ -// TODO: add keys that actually make sense... -function mimicUserTyping(el, value) { - const { _inputNode } = getComboboxMembers(el); - _inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); - // eslint-disable-next-line no-param-reassign - _inputNode.value = value; - _inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); - _inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: value })); - _inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: value })); -} - -/** - * @param {HTMLElement} el - * @param {string} key - */ -function mimicKeyPress(el, key) { - el.dispatchEvent(new KeyboardEvent('keydown', { key })); - el.dispatchEvent(new KeyboardEvent('keyup', { key })); -} - -/** - * @param {LionCombobox} el - * @param {string[]} values - */ -async function mimicUserTypingAdvanced(el, values) { - const { _inputNode } = getComboboxMembers(el); - _inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); - - for (const key of values) { - // eslint-disable-next-line no-await-in-loop, no-loop-func - await new Promise(resolve => { - const hasSelection = _inputNode.selectionStart !== _inputNode.selectionEnd; - - if (key === 'Backspace') { - if (hasSelection) { - _inputNode.value = - _inputNode.value.slice( - 0, - _inputNode.selectionStart ? _inputNode.selectionStart : undefined, - ) + - _inputNode.value.slice( - _inputNode.selectionEnd ? _inputNode.selectionEnd : undefined, - _inputNode.value.length, - ); - } else { - _inputNode.value = _inputNode.value.slice(0, -1); - } - } else if (hasSelection) { - _inputNode.value = - _inputNode.value.slice( - 0, - _inputNode.selectionStart ? _inputNode.selectionStart : undefined, - ) + - key + - _inputNode.value.slice( - _inputNode.selectionEnd ? _inputNode.selectionEnd : undefined, - _inputNode.value.length, - ); - } else { - _inputNode.value += key; - } - - mimicKeyPress(_inputNode, key); - _inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); - - el.updateComplete.then(() => { - // @ts-ignore - resolve(); - }); - }); - } -} - -/** - * @param {LionCombobox} el - */ -function getFilteredOptionValues(el) { - const options = el.formElements; - /** - * @param {{ style: { display: string; }; }} option - */ - const filtered = options.filter(option => option.getAttribute('aria-hidden') !== 'true'); - /** - * @param {{ value: any; }} option - */ - return filtered.map(option => option.value); -} - /** * @param {{ autocomplete?:'none'|'list'|'both', matchMode?:'begin'|'all' }} config */ @@ -387,6 +271,22 @@ describe('lion-combobox', () => { expect(_comboboxNode).to.exist; expect(el.querySelector('[role=combobox]')).to.equal(_comboboxNode); }); + + it('has validator "MatchesOption" applied by default', async () => { + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Item 1 + Item 2 + + `) + ); + mimicUserTyping(el, '30'); + await el.updateComplete; + expect(el.hasFeedbackFor).to.include('error'); + expect(el.validationStates).to.have.property('error'); + expect(el.validationStates.error).to.have.property('MatchesOption'); + }); }); describe('Values', () => { @@ -408,7 +308,7 @@ describe('lion-combobox', () => { expect(_inputNode.value).to.equal('20'); }); - it('sets modelValue to empty string if no option is selected', async () => { + it('sets modelValue to Unparseable if no option is selected', async () => { const el = /** @type {LionCombobox} */ ( await fixture(html` @@ -419,12 +319,59 @@ describe('lion-combobox', () => { `) ); + const { _inputNode } = getComboboxMembers(el); expect(el.modelValue).to.equal('Artichoke'); expect(el.formElements[0].checked).to.be.true; - el.checkedIndex = -1; + el.setCheckedIndex(-1); + el.__shouldAutocompleteNextUpdate = true; await el.updateComplete; - expect(el.modelValue).to.equal(''); + expect(el.modelValue instanceof Unparseable).to.be.true; + expect(el.modelValue.viewValue).to.equal('Artichoke'); + expect(el.formElements[0].checked).to.be.false; + + el.setCheckedIndex(-1); + _inputNode.value = 'Foo'; + el.__shouldAutocompleteNextUpdate = true; + await el.updateComplete; + expect(el.modelValue instanceof Unparseable).to.be.true; + expect(el.modelValue.viewValue).to.equal('Foo'); + expect(el.formElements[0].checked).to.be.false; + + el.setCheckedIndex(0); + el.__shouldAutocompleteNextUpdate = true; + await el.updateComplete; + expect(el.modelValue instanceof Unparseable).to.be.false; + expect(el.modelValue).to.equal('Artichoke'); + expect(el.formElements[0].checked).to.be.true; + }); + + it('sets modelValue to _inputNode.value if no option is selected when requireOptionMatch is false', async () => { + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + const { _inputNode } = getComboboxMembers(el); + + expect(el.modelValue).to.equal('Artichoke'); + expect(el.formElements[0].checked).to.be.true; + el.setCheckedIndex(-1); + el.__shouldAutocompleteNextUpdate = true; + await el.updateComplete; + expect(el.modelValue).to.equal('Artichoke'); + expect(el.formElements[0].checked).to.be.true; + + el.setCheckedIndex(-1); + _inputNode.value = 'Foo'; + el.__shouldAutocompleteNextUpdate = true; + await el.updateComplete; + expect(el.modelValue).to.equal('Foo'); expect(el.formElements[0].checked).to.be.false; }); @@ -439,15 +386,37 @@ describe('lion-combobox', () => { `) ); - - expect(el.modelValue).to.eql(['Artichoke']); + expect(el.modelValue).to.deep.equal(['Artichoke']); expect(el.formElements[0].checked).to.be.true; - el.checkedIndex = []; + el.setCheckedIndex([]); await el.updateComplete; - expect(el.modelValue).to.eql([]); + expect(el.modelValue).to.deep.equal([]); 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` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + 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 () => { const el = /** @type {LionCombobox} */ ( await fixture(html` @@ -479,7 +448,43 @@ describe('lion-combobox', () => { el2.clear(); expect(el2.modelValue).to.eql([]); - expect(_inputNode.value).to.equal(''); + // @ts-ignore [allow-protected] in test + expect(el2._inputNode.value).to.equal(''); + }); + + it('resets modelValue and textbox value on reset()', async () => { + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + + const { _inputNode } = getComboboxMembers(el); + el.modelValue = 'Chard'; + el.reset(); + expect(el.modelValue).to.equal('Artichoke'); + expect(_inputNode.value).to.equal('Artichoke'); + + const el2 = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + el2.modelValue = ['Artichoke', 'Chard']; + el2.reset(); + expect(el2.modelValue).to.deep.equal(['Artichoke']); + // @ts-ignore [allow-protected] in test + expect(el2._inputNode.value).to.equal('Artichoke'); }); it('syncs textbox to modelValue', async () => { @@ -493,27 +498,123 @@ describe('lion-combobox', () => { ); const { _inputNode } = getComboboxMembers(el); - async function performChecks() { + /** @param {string} autocompleteMode */ + async function performChecks(autocompleteMode) { el.formElements[0].click(); await el.updateComplete; // FIXME: fix properly for Webkit - // expect(_inputNode.value).to.equal('Aha'); - expect(el.checkedIndex).to.equal(0); + // expect(_inputNode.value).to.equal('Aha', `autocomplete mode ${autocompleteMode}`); + expect(el.checkedIndex).to.equal(0, `autocomplete mode ${autocompleteMode}`); mimicUserTyping(el, 'Ah'); await el.updateComplete; - expect(_inputNode.value).to.equal('Ah'); + expect(_inputNode.value).to.equal('Ah', `autocomplete mode ${autocompleteMode}`); await el.updateComplete; - expect(el.checkedIndex).to.equal(-1); + expect(el.checkedIndex).to.equal(-1, `autocomplete mode ${autocompleteMode}`); } el.autocomplete = 'none'; - await performChecks(); + await performChecks('none'); el.autocomplete = 'list'; - await performChecks(); + await performChecks('list'); + }); + + it('works with Required validator', async () => { + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + el.submitted = true; + await el.feedbackComplete; + expect(el.hasFeedbackFor).to.include('error', 'hasFeedbackFor'); + await el.feedbackComplete; + expect(el.showsFeedbackFor).to.include('error', 'showsFeedbackFor'); + }); + + it('allows a value outside of the option list when requireOptionMatch is false', async () => { + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + el.requireOptionMatch = false; + const { _inputNode } = getComboboxMembers(el); + expect(el.checkedIndex).to.equal(0); + + mimicUserTyping(el, 'Foo'); + _inputNode.dispatchEvent(new Event('input')); + await el.updateComplete; + + expect(el.checkedIndex).to.equal(-1); + expect(el.modelValue).to.equal('Foo'); + expect(_inputNode.value).to.equal('Foo'); + }); + + it("when removing a letter it won't select the option", async () => { + // We don't autocomplete when characters are removed + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + const { _inputNode } = getComboboxMembers(el); + expect(el.checkedIndex).to.equal(0); + + // Simulate backspace deleting the char at the end of the string + mimicKeyPress(_inputNode, 'Backspace'); + _inputNode.dispatchEvent(new Event('input')); + const arr = _inputNode.value.split(''); + arr.splice(_inputNode.value.length - 1, 1); + _inputNode.value = arr.join(''); + await el.updateComplete; + el.dispatchEvent(new Event('blur')); + + expect(el.checkedIndex).to.equal(-1); + expect(el.modelValue instanceof Unparseable).to.be.true; + expect(el.modelValue.viewValue).to.equal('Artichok'); + }); + + it('allows the user to override the parser', async () => { + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + const { _inputNode } = getComboboxMembers(el); + + el.setCheckedIndex(-1); + _inputNode.value = 'Foo123'; + el.__shouldAutocompleteNextUpdate = true; + await el.updateComplete; + expect(el.modelValue).to.equal('Foo'); + expect(el.formElements[0].checked).to.be.false; }); }); @@ -874,36 +975,6 @@ describe('lion-combobox', () => { }); }); - it('works with validation', async () => { - const el = /** @type {LionCombobox} */ ( - await fixture(html` - - Artichoke - Chard - Chicory - Victoria Plum - - `) - ); - const { _inputNode } = getComboboxMembers(el); - expect(el.checkedIndex).to.equal(0); - - // Simulate backspace deleting the char at the end of the string - mimicKeyPress(_inputNode, 'Backspace'); - _inputNode.dispatchEvent(new Event('input')); - const arr = _inputNode.value.split(''); - arr.splice(_inputNode.value.length - 1, 1); - _inputNode.value = arr.join(''); - await el.updateComplete; - el.dispatchEvent(new Event('blur')); - - expect(el.checkedIndex).to.equal(-1); - await el.feedbackComplete; - expect(el.hasFeedbackFor).to.include('error', 'hasFeedbackFor'); - await el.feedbackComplete; - expect(el.showsFeedbackFor).to.include('error', 'showsFeedbackFor'); - }); - it('dropdown has a label', async () => { const el = /** @type {LionCombobox} */ ( await fixture(html` @@ -1656,7 +1727,7 @@ describe('lion-combobox', () => { const el = /** @type {LionCombobox} */ ( await fixture(html` - <${tag} name="foo" autocomplete="list" opened> + <${tag} name="foo" opened> Artichoke Chard Chicory @@ -1948,7 +2019,10 @@ describe('lion-combobox', () => { */ // eslint-disable-next-line class-methods-use-this _getTextboxValueFromOption(option) { - return option.label; + if (option && option.label) { + return option.label; + } + return this.modelValue; } } const tagName = defineCE(X); @@ -1995,7 +2069,10 @@ describe('lion-combobox', () => { */ // eslint-disable-next-line class-methods-use-this _getTextboxValueFromOption(option) { - return option.label; + if (option && option.label) { + return option.label; + } + return this.modelValue; } } const tagName = defineCE(X); @@ -2068,29 +2145,29 @@ describe('lion-combobox', () => { ); const { _inputNode } = getComboboxMembers(el); - async function performChecks() { + /** @param {string} autocompleteMode */ + async function performChecks(autocompleteMode) { await el.updateComplete; el.formElements[0].click(); - await el.updateComplete; // FIXME: fix properly for Webkit - // expect(_inputNode.value).to.equal('Aha'); - expect(el.checkedIndex).to.equal(0); + // expect(_inputNode.value).to.equal('Aha', autocompleteMode); + expect(el.checkedIndex).to.equal(0, autocompleteMode); mimicUserTyping(el, 'Arti'); await el.updateComplete; - expect(_inputNode.value).to.equal('Arti'); + expect(_inputNode.value).to.equal('Arti', autocompleteMode); await el.updateComplete; - expect(el.checkedIndex).to.equal(-1); + expect(el.checkedIndex).to.equal(-1, autocompleteMode); } el.autocomplete = 'none'; - await performChecks(); + await performChecks('none'); el.autocomplete = 'list'; - await performChecks(); + await performChecks('list'); }); }); @@ -2233,7 +2310,7 @@ describe('lion-combobox', () => { // eslint-disable-next-line no-param-reassign elm.activeIndex = -1; // eslint-disable-next-line no-param-reassign - elm.checkedIndex = -1; + elm.setCheckedIndex(-1); // eslint-disable-next-line no-param-reassign elm.opened = true; await elm.updateComplete; diff --git a/packages/ui/components/combobox/test/validators.test.js b/packages/ui/components/combobox/test/validators.test.js new file mode 100644 index 000000000..9fa99e43c --- /dev/null +++ b/packages/ui/components/combobox/test/validators.test.js @@ -0,0 +1,62 @@ +import { mimicUserTyping } from '@lion/ui/combobox-test-helpers.js'; +import { MatchesOption } from '@lion/ui/combobox.js'; +import '@lion/ui/define/lion-combobox.js'; +import '@lion/ui/define/lion-option.js'; +import { expect, fixture, html } from '@open-wc/testing'; + +/** + * @typedef {import('@lion/ui/combobox.js').LionCombobox} LionCombobox + */ + +describe('MatchesOption validation', () => { + it('is enabled when an invalid value is set', async () => { + let isEnabled; + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + const config = {}; + config.node = el; + const validator = new MatchesOption(); + + mimicUserTyping(el, 'Artichoke'); + await el.updateComplete; + + isEnabled = validator.execute('Artichoke', undefined, config); + expect(isEnabled).to.be.false; + + mimicUserTyping(el, 'Foo'); + await el.updateComplete; + + isEnabled = validator.execute('Foo', undefined, config); + expect(isEnabled).to.be.true; + }); + + it('is not enabled when empty is set', async () => { + const el = /** @type {LionCombobox} */ ( + await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `) + ); + const config = {}; + config.node = el; + const validator = new MatchesOption(); + + el.modelValue = ''; + await el.updateComplete; + + const isEnabled = validator.execute('', undefined, config); + expect(isEnabled).to.be.false; + }); +}); diff --git a/packages/ui/components/form-core/types/choice-group/ChoiceGroupMixinTypes.ts b/packages/ui/components/form-core/types/choice-group/ChoiceGroupMixinTypes.ts index 02289d001..ad203dca2 100644 --- a/packages/ui/components/form-core/types/choice-group/ChoiceGroupMixinTypes.ts +++ b/packages/ui/components/form-core/types/choice-group/ChoiceGroupMixinTypes.ts @@ -1,9 +1,10 @@ import { Constructor } from '@open-wc/dedupe-mixin'; import { LitElement } from 'lit'; -import { FormControlHost } from '../FormControlMixinTypes.js'; import { FormControl } from '../form-group/FormGroupMixinTypes.js'; -import { FormRegistrarHost } from '../registration/FormRegistrarMixinTypes.js'; +import { FormControlHost } from '../FormControlMixinTypes.js'; import { InteractionStateHost } from '../InteractionStateMixinTypes.js'; +import { FormRegistrarHost } from '../registration/FormRegistrarMixinTypes.js'; +import { ChoiceInputHost } from './ChoiceInputMixinTypes.js'; export declare class ChoiceGroupHost { multipleChoice: boolean; @@ -26,7 +27,7 @@ export declare class ChoiceGroupHost { protected _throwWhenInvalidChildModelValue(child: FormControlHost): void; protected _isEmpty(): void; protected _checkSingleChoiceElements(ev: Event): void; - protected _getCheckedElements(): void; + protected _getCheckedElements(): ChoiceInputHost[]; protected _setCheckedElements(value: any, check: boolean): void; protected _onBeforeRepropagateChildrenValues(ev: Event): void; diff --git a/packages/ui/components/listbox/test-suites/ListboxMixin.suite.js b/packages/ui/components/listbox/test-suites/ListboxMixin.suite.js index bc3923abe..107a803c7 100644 --- a/packages/ui/components/listbox/test-suites/ListboxMixin.suite.js +++ b/packages/ui/components/listbox/test-suites/ListboxMixin.suite.js @@ -1459,7 +1459,7 @@ export function runListboxMixinSuite(customConfig = {}) { )} `); - el.checkedIndex = 0; + el.setCheckedIndex(0); expect(el.modelValue).to.deep.equal({ type: 'mastercard', label: 'Master Card', @@ -1467,7 +1467,7 @@ export function runListboxMixinSuite(customConfig = {}) { active: true, }); - el.checkedIndex = 1; + el.setCheckedIndex(1); expect(el.modelValue).to.deep.equal({ type: 'visacard', label: 'Visa Card', diff --git a/packages/ui/components/validate-messages/src/loadDefaultFeedbackMessagesNoSideEffects.js b/packages/ui/components/validate-messages/src/loadDefaultFeedbackMessagesNoSideEffects.js index b383cf939..0662ef3d4 100644 --- a/packages/ui/components/validate-messages/src/loadDefaultFeedbackMessagesNoSideEffects.js +++ b/packages/ui/components/validate-messages/src/loadDefaultFeedbackMessagesNoSideEffects.js @@ -1,22 +1,23 @@ /* eslint-disable import/no-extraneous-dependencies */ +import { MatchesOption } from '@lion/ui/combobox.js'; import { DefaultSuccess, + EqualsLength, IsDate, IsDateDisabled, - MaxDate, - MinDate, - MinMaxDate, + IsEmail, IsNumber, + MaxDate, + MaxLength, MaxNumber, + MinDate, + MinLength, + MinMaxDate, + MinMaxLength, MinMaxNumber, MinNumber, - Required, - EqualsLength, - IsEmail, - MaxLength, - MinLength, - MinMaxLength, Pattern, + Required, } from '@lion/ui/form-core.js'; import { PhoneNumber } from '@lion/ui/input-tel.js'; @@ -162,6 +163,8 @@ export function loadDefaultFeedbackMessagesNoSideEffects({ localize }) { MinMaxDate.getMessage = async data => getLocalizedMessage(data); /** @param {FeedbackMessageData} data */ IsDateDisabled.getMessage = async data => getLocalizedMessage(data); + /** @param {FeedbackMessageData} data */ + MatchesOption.getMessage = async data => getLocalizedMessage(data); DefaultSuccess.getMessage = async data => { await forMessagesToBeReady(); diff --git a/packages/ui/components/validate-messages/translations/bg.js b/packages/ui/components/validate-messages/translations/bg.js index 909a3209c..a55677685 100644 --- a/packages/ui/components/validate-messages/translations/bg.js +++ b/packages/ui/components/validate-messages/translations/bg.js @@ -17,6 +17,8 @@ export default { 'Моля, въведете {fieldName} между {params.min, date, YYYYMMDD} и {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Тази дата не е на разположение, моля, изберете друга.', IsEmail: 'Моля, въведете валиден {fieldName} с формат "name@example.com".', + MatchesOption: + 'Не са открити съответстващи резултати. Моля, опитайте с друга ключова дума или категория.', }, warning: { Required: 'Моля, въведете също {fieldName}.', @@ -35,6 +37,8 @@ export default { 'Моля, въведете {fieldName} между {params.min, date, YYYYMMDD} и {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Тази дата не е на разположение, моля, изберете друга.', IsEmail: 'Моля, въведете валиден {fieldName} с формат "name@example.com".', + MatchesOption: + 'Не са открити съответстващи резултати. Моля, опитайте с друга ключова дума или категория.', }, success: { DefaultOk: 'Добре', diff --git a/packages/ui/components/validate-messages/translations/cs.js b/packages/ui/components/validate-messages/translations/cs.js index ed20aeaa3..1ee9a9e37 100644 --- a/packages/ui/components/validate-messages/translations/cs.js +++ b/packages/ui/components/validate-messages/translations/cs.js @@ -17,6 +17,7 @@ export default { 'Zadejte {fieldName} od {params.min, date, YYYYMMDD} do {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Toto datum je nedostupné, vyberte jiné.', IsEmail: 'Zadejte platný {fieldName} ve formátu "name@example.com".', + MatchesOption: 'Žádné odpovídající výsledky. Zkuste jiné klíčové slovo nebo kategorii.', }, warning: { Required: 'Zadejte rovněž {fieldName}.', @@ -35,6 +36,7 @@ export default { 'Zadejte {fieldName} od {params.min, date, YYYYMMDD} do {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Toto datum je nedostupné, vyberte jiné.', IsEmail: 'Zadejte platný {fieldName} ve formátu "name@example.com".', + MatchesOption: 'Žádné odpovídající výsledky. Zkuste jiné klíčové slovo nebo kategorii.', }, success: { DefaultOk: 'Dobře', diff --git a/packages/ui/components/validate-messages/translations/de.js b/packages/ui/components/validate-messages/translations/de.js index 5a8107ba8..f54cb875c 100644 --- a/packages/ui/components/validate-messages/translations/de.js +++ b/packages/ui/components/validate-messages/translations/de.js @@ -18,6 +18,8 @@ export default { 'Geben Sie für {fieldName} einen Wert zwischen {params.min, date, YYYYMMDD} und {params.max, date, YYYYMMDD} ein.', IsDateDisabled: 'Dieses Datum ist nicht verfügbar, bitte wählen Sie ein anderes Datum.', IsEmail: 'Geben Sie einen gültige {fieldName} im Format „name@example.com“ ein.', + MatchesOption: + 'Keine übereinstimmenden Ergebnisse. Bitte versuchen Sie es mit einem anderen Schlüsselbegriff oder einer anderen Kategorie.', }, warning: { Required: '{fieldName} sollte ausgefüllt werden.', @@ -37,6 +39,8 @@ export default { 'Geben Sie für {fieldName} einen Wert zwischen {params.min, date, YYYYMMDD} und {params.max, date, YYYYMMDD} ein.', IsDateDisabled: 'Dieses Datum ist nicht verfügbar, bitte wählen Sie ein anderes Datum.', IsEmail: 'Geben Sie einen gültige {fieldName} im Format „name@example.com“ ein.', + MatchesOption: + 'Keine übereinstimmenden Ergebnisse. Bitte versuchen Sie es mit einem anderen Schlüsselbegriff oder einer anderen Kategorie.', }, success: { DefaultOk: 'OK', diff --git a/packages/ui/components/validate-messages/translations/en.js b/packages/ui/components/validate-messages/translations/en.js index 4acce51c8..ac6c6b224 100644 --- a/packages/ui/components/validate-messages/translations/en.js +++ b/packages/ui/components/validate-messages/translations/en.js @@ -18,6 +18,7 @@ export default { 'Please enter a {fieldName} between {params.min, date, YYYYMMDD} and {params.max, date, YYYYMMDD}.', IsDateDisabled: 'This date is unavailable, please choose another one.', IsEmail: 'Please enter a valid {fieldName} in the format "name@example.com".', + MatchesOption: 'No matching results. Please try a different keyword or category.', }, warning: { Required: 'Please enter a(n) {fieldName}.', @@ -37,6 +38,7 @@ export default { 'Please enter a {fieldName} between {params.min, date, YYYYMMDD} and {params.max, date, YYYYMMDD}.', IsDateDisabled: 'This date is unavailable, please choose another one.', IsEmail: 'Please enter a valid {fieldName} in the format "name@example.com".', + MatchesOption: 'No matching results. Please try a different keyword or category.', }, success: { DefaultOk: 'Okay', diff --git a/packages/ui/components/validate-messages/translations/es.js b/packages/ui/components/validate-messages/translations/es.js index fd79e1608..75eaa4a48 100644 --- a/packages/ui/components/validate-messages/translations/es.js +++ b/packages/ui/components/validate-messages/translations/es.js @@ -18,6 +18,8 @@ export default { 'Introduzca un/a {fieldName} entre {params.min, date, YYYYMMDD} y {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Esta fecha no está disponible. Elija otra.', IsEmail: 'Introduzca un/a {fieldName} válido/a con el formato "nombre@ejemplo.com".', + MatchesOption: + 'No hay resultados que coincidan. Pruebe con una palabra clave o categoría diferente.', }, warning: { Required: 'Introduzca también un/a {fieldName}.', @@ -37,6 +39,8 @@ export default { 'Introduzca un/a {fieldName} entre {params.min, date, YYYYMMDD} y {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Esta fecha no está disponible. Elija otra.', IsEmail: 'Introduzca un/a {fieldName} válido/a con el formato "nombre@ejemplo.com".', + MatchesOption: + 'No hay resultados que coincidan. Pruebe con una palabra clave o categoría diferente.', }, success: { DefaultOk: 'Vale', diff --git a/packages/ui/components/validate-messages/translations/fr.js b/packages/ui/components/validate-messages/translations/fr.js index 3ec59ad39..0c0e8b511 100644 --- a/packages/ui/components/validate-messages/translations/fr.js +++ b/packages/ui/components/validate-messages/translations/fr.js @@ -19,6 +19,8 @@ export default { 'Veuillez indiquer un(e) {fieldName} entre {params.min, date, YYYYMMDD} et {params.max, date, YYYYMMDD}.', IsDateDisabled: "Cette date n'est pas disponible, veuillez en choisir une autre.", IsEmail: 'Veuillez indiquer un(e) {fieldName} au format "nom@exemple.com".', + MatchesOption: + 'Aucun résultat correspondant. Veuillez essayer un autre mot-clé ou une autre catégorie.', }, warning: { Required: 'Veuillez également indiquer un(e) {fieldName}.', @@ -39,6 +41,8 @@ export default { 'Veuillez indiquer un(e) {fieldName} entre {params.min, date, YYYYMMDD} et {params.max, date, YYYYMMDD}.', IsDateDisabled: "Cette date n'est pas disponible, veuillez en choisir une autre.", IsEmail: 'Veuillez indiquer un(e) {fieldName} au format "nom@exemple.com".', + MatchesOption: + 'Aucun résultat correspondant. Veuillez essayer un autre mot-clé ou une autre catégorie.', }, success: { DefaultOk: 'Ok', diff --git a/packages/ui/components/validate-messages/translations/hu.js b/packages/ui/components/validate-messages/translations/hu.js index bd3b7ffa2..9ddbe0189 100644 --- a/packages/ui/components/validate-messages/translations/hu.js +++ b/packages/ui/components/validate-messages/translations/hu.js @@ -19,6 +19,7 @@ export default { IsDateDisabled: 'Ez a dátum nem áll rendelkezésre, válasszon egy másikat.', IsEmail: 'Adjon meg egy érvényes {fieldName} értéket, a következő formátumban: „név@példa.com”.', + MatchesOption: 'Nincs egyező találat. Próbálkozzon másik kulcsszóval vagy kategóriával.', }, warning: { Required: 'Továbbá adjon meg egy {fieldName} értéket.', @@ -39,6 +40,7 @@ export default { IsDateDisabled: 'Ez a dátum nem áll rendelkezésre, válasszon egy másikat.', IsEmail: 'Adjon meg egy érvényes {fieldName} értéket, a következő formátumban: „név@példa.com”.', + MatchesOption: 'Nincs egyező találat. Próbálkozzon másik kulcsszóval vagy kategóriával.', }, success: { DefaultOk: 'Rendben', diff --git a/packages/ui/components/validate-messages/translations/it.js b/packages/ui/components/validate-messages/translations/it.js index 096891345..c57f71afd 100644 --- a/packages/ui/components/validate-messages/translations/it.js +++ b/packages/ui/components/validate-messages/translations/it.js @@ -18,6 +18,8 @@ export default { 'Inserire un(a) {fieldName} tra {params.min, date, YYYYMMDD} e {params.max, date, YYYYMMDD}.', IsDateDisabled: "ТQuesta data non è disponibile, sceglierne un'altra.", IsEmail: 'Inserire un valore valido per {fieldName} nel formato "name@example.com".', + MatchesOption: + 'Nessun risultato corrispondente. Provare con una parola chiave o una categoria diversa.', }, warning: { Required: 'Inserire anche un(a) {fieldName}.', @@ -37,6 +39,8 @@ export default { 'Inserire un(a) {fieldName} tra {params.min, date, YYYYMMDD} e {params.max, date, YYYYMMDD}.', IsDateDisabled: "ТQuesta data non è disponibile, sceglierne un'altra.", IsEmail: 'Inserire un valore valido per {fieldName} nel formato "name@example.com".', + MatchesOption: + 'Nessun risultato corrispondente. Provare con una parola chiave o una categoria diversa.', }, success: { DefaultOk: 'OK', diff --git a/packages/ui/components/validate-messages/translations/nl.js b/packages/ui/components/validate-messages/translations/nl.js index 40253fc09..4cc1ba3ba 100644 --- a/packages/ui/components/validate-messages/translations/nl.js +++ b/packages/ui/components/validate-messages/translations/nl.js @@ -17,6 +17,8 @@ export default { 'Vul een {fieldName} in tussen {params.min, date, YYYYMMDD} en {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Deze datum is niet mogelijk, kies een andere.', IsEmail: 'Vul een {fieldName} in formaat "name@example.com".', + MatchesOption: + 'Geen overeenkomende resultaten. Probeer een ander trefwoord of een andere categorie.', }, warning: { Required: 'Vul een {fieldName} in.', @@ -35,6 +37,8 @@ export default { 'Vul een {fieldName} in tussen {params.min, date, YYYYMMDD} en {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Deze datum is niet mogelijk, kies een andere.', IsEmail: 'Vul een {fieldName} in formaat "name@example.com".', + MatchesOption: + 'Geen overeenkomende resultaten. Probeer een ander trefwoord of een andere categorie.', }, success: { DefaultOk: 'Okee', diff --git a/packages/ui/components/validate-messages/translations/pl.js b/packages/ui/components/validate-messages/translations/pl.js index 9c33cfd98..8217d2271 100644 --- a/packages/ui/components/validate-messages/translations/pl.js +++ b/packages/ui/components/validate-messages/translations/pl.js @@ -18,6 +18,7 @@ export default { 'Proszę podać wartość {fieldName} między {params.min, date, YYYYMMDD} a {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Ta data jest niedostępna, wybierz inną.', IsEmail: 'Proszę podać prawidłowy {fieldName} w formacie „nazwa@example.com”.', + MatchesOption: 'Brak pasujących wyników. Spróbuj użyć innego słowa kluczowego lub kategorii.', }, warning: { Required: 'Proszę również podać wartość {fieldName}.', @@ -37,6 +38,7 @@ export default { 'Proszę podać wartość {fieldName} między {params.min, date, YYYYMMDD} a {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Ta data jest niedostępna, wybierz inną.', IsEmail: 'Proszę podać prawidłowy {fieldName} w formacie „nazwa@example.com”.', + MatchesOption: 'Brak pasujących wyników. Spróbuj użyć innego słowa kluczowego lub kategorii.', }, success: { DefaultOk: 'Ok', diff --git a/packages/ui/components/validate-messages/translations/ro.js b/packages/ui/components/validate-messages/translations/ro.js index 8ffa29561..01787944f 100644 --- a/packages/ui/components/validate-messages/translations/ro.js +++ b/packages/ui/components/validate-messages/translations/ro.js @@ -18,6 +18,8 @@ export default { 'Introduceți un/o {fieldName} cuprins(ă) între {params.min, date, YYYYMMDD} și {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Această dată nu este disponibilă, alegeți alta.', IsEmail: 'Introduceți un/o {fieldName} valid(ă) în formatul „nume@exemplu.com”.', + MatchesOption: + 'Niciun rezultat corespunzător. Încercaţi un cuvânt cheie sau o categorie diferită.', }, warning: { Required: 'Introduceți un/o {fieldName}.', @@ -37,6 +39,8 @@ export default { 'Introduceți un/o {fieldName} cuprins(ă) între {params.min, date, YYYYMMDD} și {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Această dată nu este disponibilă, alegeți alta.', IsEmail: 'Introduceți un/o {fieldName} valid(ă) în formatul „nume@exemplu.com”.', + MatchesOption: + 'Niciun rezultat corespunzător. Încercaţi un cuvânt cheie sau o categorie diferită.', }, success: { DefaultOk: 'În regulă', diff --git a/packages/ui/components/validate-messages/translations/ru.js b/packages/ui/components/validate-messages/translations/ru.js index 6b06a6104..e956760c3 100644 --- a/packages/ui/components/validate-messages/translations/ru.js +++ b/packages/ui/components/validate-messages/translations/ru.js @@ -18,6 +18,8 @@ export default { 'Введите значение поля {fieldName} от {params.min, date, YYYYMMDD} до {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Эта дата недоступна, выберите другой вариант.', IsEmail: 'Введите действительное значение поля {fieldName} в формате «name@example.com».', + MatchesOption: + 'Нет соответствующих результатов. Попробуйте указать другое ключевое слово или категорию.', }, warning: { Required: 'Введите значение поля {fieldName}.', @@ -37,6 +39,8 @@ export default { 'Введите значение поля {fieldName} от {params.min, date, YYYYMMDD} до {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Эта дата недоступна, выберите другой вариант.', IsEmail: 'Введите действительное значение поля {fieldName} в формате «name@example.com».', + MatchesOption: + 'Нет соответствующих результатов. Попробуйте указать другое ключевое слово или категорию.', }, success: { DefaultOk: 'OK', diff --git a/packages/ui/components/validate-messages/translations/sk.js b/packages/ui/components/validate-messages/translations/sk.js index 73d8b58a2..2fd63ce7d 100644 --- a/packages/ui/components/validate-messages/translations/sk.js +++ b/packages/ui/components/validate-messages/translations/sk.js @@ -17,6 +17,7 @@ export default { 'Uveďte {fieldName} od {params.min, date, YYYYMMDD} do {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Tento dátum je nedostupný, vyberte iný.', IsEmail: 'Uveďte platnú položku {fieldName} vo formáte „meno@príklad.com“.', + MatchesOption: 'Žiadne vyhovujúce výsledky. Skúste iné kľúčové slovo alebo kategóriu.', }, warning: { Required: 'Uveďte aj {fieldName}.', @@ -35,6 +36,7 @@ export default { 'Uveďte {fieldName} od {params.min, date, YYYYMMDD} do {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Tento dátum je nedostupný, vyberte iný.', IsEmail: 'Uveďte platnú položku {fieldName} vo formáte „meno@príklad.com“.', + MatchesOption: 'Žiadne vyhovujúce výsledky. Skúste iné kľúčové slovo alebo kategóriu.', }, success: { DefaultOk: 'Dobre', diff --git a/packages/ui/components/validate-messages/translations/uk.js b/packages/ui/components/validate-messages/translations/uk.js index 1dac856bc..f1f0e2379 100644 --- a/packages/ui/components/validate-messages/translations/uk.js +++ b/packages/ui/components/validate-messages/translations/uk.js @@ -19,6 +19,8 @@ export default { 'Уведіть значення {fieldName} між {params.min, date, YYYYMMDD} та {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Ця дата недоступна, виберіть іншу.', IsEmail: 'Уведіть допустиме значення {fieldName} у форматі name@example.com.', + MatchesOption: + 'Не знайдено відповідних результатів. Спробуйте інше ключове слово чи категорію.', }, warning: { Required: 'Уведіть також значення {fieldName}.', @@ -39,6 +41,8 @@ export default { 'Уведіть значення {fieldName} між {params.min, date, YYYYMMDD} та {params.max, date, YYYYMMDD}.', IsDateDisabled: 'Ця дата недоступна, виберіть іншу.', IsEmail: 'Уведіть допустиме значення {fieldName} у форматі name@example.com.', + MatchesOption: + 'Не знайдено відповідних результатів. Спробуйте інше ключове слово чи категорію.', }, success: { DefaultOk: 'Добре', diff --git a/packages/ui/components/validate-messages/translations/zh.js b/packages/ui/components/validate-messages/translations/zh.js index d9ede8a02..0850e0e9e 100644 --- a/packages/ui/components/validate-messages/translations/zh.js +++ b/packages/ui/components/validate-messages/translations/zh.js @@ -17,6 +17,7 @@ export default { '请在{params.min,date,YYYYMMDD}和{params.max,date,YYYYMMDD}之间输入{fieldName}。', IsDateDisabled: '此日期不可用,请选择其他日期。', IsEmail: '请输入格式为"name@example.com"的有效{fieldName}。', + MatchesOption: '无匹配结果。请尝试其他关键词或类别。', }, warning: { Required: '請輸入{fieldName}。', @@ -35,6 +36,7 @@ export default { '请在{params.min,date,YYYYMMDD}和{params.max,date,YYYYMMDD}之间输入{fieldName}。', IsDateDisabled: '此日期不可用,请选择其他日期。', IsEmail: '请输入格式为"name@example.com"的有效{fieldName}。', + MatchesOption: '无匹配结果。请尝试其他关键词或类别。', }, success: { DefaultOk: '好的', diff --git a/packages/ui/exports/combobox-test-helpers.js b/packages/ui/exports/combobox-test-helpers.js new file mode 100644 index 000000000..243bfde9a --- /dev/null +++ b/packages/ui/exports/combobox-test-helpers.js @@ -0,0 +1 @@ +export * from '../components/combobox/test-helpers/combobox-helpers.js'; diff --git a/packages/ui/exports/combobox.js b/packages/ui/exports/combobox.js index 8309c9357..726f3abdc 100644 --- a/packages/ui/exports/combobox.js +++ b/packages/ui/exports/combobox.js @@ -3,3 +3,4 @@ export { makeMatchingTextBold, unmakeMatchingTextBold, } from '../components/combobox/src/utils/makeMatchingTextBold.js'; +export { MatchesOption } from '../components/combobox/src/validators.js';