diff --git a/.changeset/sharp-plants-shave.md b/.changeset/sharp-plants-shave.md new file mode 100644 index 000000000..21f56789c --- /dev/null +++ b/.changeset/sharp-plants-shave.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': patch +--- + +feat: allow Required validator on Fieldset and Form; `static executesOnEmpty` flag on Validators diff --git a/packages/ui/components/form-core/src/form-group/FormGroupMixin.js b/packages/ui/components/form-core/src/form-group/FormGroupMixin.js index f37aa195e..a0d8cb104 100644 --- a/packages/ui/components/form-core/src/form-group/FormGroupMixin.js +++ b/packages/ui/components/form-core/src/form-group/FormGroupMixin.js @@ -584,6 +584,18 @@ const FormGroupMixinImplementation = superclass => } this.__unlinkParentMessages(el); } + + /** + * @override FormControlMixin + */ + _isEmpty() { + for (const el of this.formElements) { + if (!el._isEmpty?.()) { + return false; + } + } + return true; + } }; export const FormGroupMixin = dedupeMixin(FormGroupMixinImplementation); diff --git a/packages/ui/components/form-core/src/validate/ValidateMixin.js b/packages/ui/components/form-core/src/validate/ValidateMixin.js index f61d219dc..a901123f8 100644 --- a/packages/ui/components/form-core/src/validate/ValidateMixin.js +++ b/packages/ui/components/form-core/src/validate/ValidateMixin.js @@ -8,7 +8,7 @@ import { AsyncQueue } from '../utils/AsyncQueue.js'; import { pascalCase } from '../utils/pascalCase.js'; import { SyncUpdatableMixin } from '../utils/SyncUpdatableMixin.js'; import { LionValidationFeedback } from './LionValidationFeedback.js'; -import { ResultValidator } from './ResultValidator.js'; +import { ResultValidator as MetaValidator } from './ResultValidator.js'; import { Unparseable } from './Unparseable.js'; import { Validator } from './Validator.js'; import { Required } from './validators/Required.js'; @@ -20,6 +20,7 @@ import { FormControlMixin } from '../FormControlMixin.js'; * @typedef {import('../../types/validate/ValidateMixinTypes.js').ValidateMixin} ValidateMixin * @typedef {import('../../types/validate/ValidateMixinTypes.js').ValidationType} ValidationType * @typedef {import('../../types/validate/ValidateMixinTypes.js').ValidateHost} ValidateHost + * @typedef {import('../../types/validate/index.js').ValidatorOutcome} ValidatorOutcome * @typedef {typeof import('../../types/validate/ValidateMixinTypes.js').ValidateHost} ValidateHostConstructor * @typedef {{validator:Validator; outcome:boolean|string}} ValidationResultEntry * @typedef {{[type:string]: {[validatorName:string]:boolean|string}}} ValidationStates @@ -33,6 +34,15 @@ function arrayDiff(array1 = [], array2 = []) { return array1.filter(x => !array2.includes(x)).concat(array2.filter(x => !array1.includes(x))); } +/** + * When the modelValue can't be created by FormatMixin.parser, still allow all validators + * to give valuable feedback to the user based on the current viewValue. + * @param {any} modelValue + */ +function getValueForValidators(modelValue) { + return modelValue instanceof Unparseable ? modelValue.viewValue : modelValue; +} + /** * Handles all validation, based on modelValue changes. It has no knowledge about dom and * UI. All error visibility, dom interaction and accessibility are handled in FeedbackMixin. @@ -46,17 +56,13 @@ export const ValidateMixinImplementation = superclass => SyncUpdatableMixin(DisabledMixin(SlotMixin(ScopedElementsMixin(superclass)))), ) { static get scopedElements() { - const scopedElementsCtor = - /** @type {typeof import('@open-wc/scoped-elements/types.js').ScopedElementsHost} */ ( - super.constructor - ); return { - ...scopedElementsCtor.scopedElements, + // @ts-ignore + ...super.scopedElements, 'lion-validation-feedback': LionValidationFeedback, }; } - /** @type {any} */ static get properties() { return { validators: { attribute: false }, @@ -93,8 +99,8 @@ export const ValidateMixinImplementation = superclass => } /** - * @overridable * Adds "._feedbackNode" as described below + * @public */ get slots() { /** @@ -229,7 +235,7 @@ export const ValidateMixinImplementation = superclass => this.__asyncValidationResult = []; /** - * Aggregated result from sync Validators, async Validators and ResultValidators + * Aggregated result from sync Validators, async Validators and MetaValidators * @type {ValidationResultEntry[]} * @private */ @@ -242,6 +248,7 @@ export const ValidateMixinImplementation = superclass => this.__prevValidationResult = []; /** + * The shown validation result depends on the visibility of the feedback messages * @type {ValidationResultEntry[]} * @private */ @@ -279,7 +286,7 @@ export const ValidateMixinImplementation = superclass => */ firstUpdated(changedProperties) { super.firstUpdated(changedProperties); - this.__validateInitialized = true; + this.__isValidateInitialized = true; this.validate(); if (this._repropagationRole !== 'child') { this.addEventListener('model-value-changed', () => { @@ -360,8 +367,8 @@ export const ValidateMixinImplementation = superclass => * Executions are scheduled and awaited and the 'async results' are merged with the * 'sync results'. * - * - b) there are ResultValidators. After steps a1, a2, or a3 are finished, the holistic - * ResultValidators (evaluating the total result of the 'regular' (a1, a2 and a3) validators) + * - b) there are MetaValidators. After steps a1, a2, or a3 are finished, the holistic + * MetaValidators (evaluating the total result of the 'regular' (a1, a2 and a3) validators) * will be run... * * Situations a2 and a3 are not mutually exclusive and can be triggered within one `validate()` @@ -369,14 +376,27 @@ export const ValidateMixinImplementation = superclass => * * @param {{ clearCurrentResult?: boolean }} opts */ - async validate({ clearCurrentResult } = {}) { + async validate({ clearCurrentResult = false } = {}) { + /** + * Allows Application Developer to wait for (async) validation + * @example + * ```js + * await el.validateComplete; + * ``` + * @type {Promise} + */ + this.validateComplete = new Promise(resolve => { + this.__validateCompleteResolve = resolve; + }); + if (this.disabled) { this.__clearValidationResults(); - this.__finishValidation({ source: 'sync', hasAsync: true }); + this.__finishValidationPass(); this._updateFeedbackComponent(); return; } - if (!this.__validateInitialized) { + // We don't validate before firstUpdated has run + if (!this.__isValidateInitialized) { return; } @@ -394,131 +414,144 @@ export const ValidateMixinImplementation = superclass => * @desc step a1-3 + b (as explained in `validate()`) */ async __executeValidators() { - /** - * Allows Application Developer to wait for (async) validation - * @example - * ```js - * await el.validateComplete; - * ``` - * @type {Promise} - */ - this.validateComplete = new Promise(resolve => { - this.__validateCompleteResolve = resolve; - }); - - // When the modelValue can't be created by FormatMixin.parser, still allow all validators - // to give valuable feedback to the user based on the current viewValue. - const value = - this.modelValue instanceof Unparseable ? this.modelValue.viewValue : this.modelValue; - - /** @type {Validator | undefined} */ - const requiredValidator = this._allValidators.find(v => v instanceof Required); + const value = getValueForValidators(this.modelValue); /** - * 1. Handle the 'exceptional' Required validator: - * - the validatity is dependent on the formControl type and therefore determined - * by the formControl.__isEmpty method. Basically, the Required Validator is a means - * to trigger formControl.__isEmpty. - * - when __isEmpty returns true, the input was empty. This means we need to stop + * Handle the 'mutually exclusive' Required validator: + * + * About the Required Validator: + * - the validity is dependent on the formControls' modelValue and therefore determined + * by the formControl._isEmpty method. Basically, the Required Validator is a means + * to trigger formControl._isEmpty(). + * + * About the empty state: + * - when _isEmpty returns true, the modelValue is considered empty. This means we need to stop * validation here, because all other Validators' execute functions assume the * value is not empty (there would be nothing to validate). + * */ - // TODO: Try to remove this when we have a single lion form core package, because then we can - // depend on FormControlMixin directly, and _isEmpty will always be an existing method on the prototype then + const isEmpty = this.__isEmpty(value); + + this.__syncValidationResult = []; if (isEmpty) { + const requiredValidator = this._allValidators.find(v => v instanceof Required); if (requiredValidator) { this.__syncValidationResult = [{ validator: requiredValidator, outcome: true }]; } - this.__finishValidation({ source: 'sync' }); - return; + // TODO: get rid of _isFormOrFieldset in a breaking future update. + // For now, We should keep forms and fieldsets backwards compatible... + const validatorsThatShouldRunOnEmpty = this._allValidators.filter( + v => v.constructor.executesOnEmpty, + ); + const shouldHaltValidationOnEmpty = + !validatorsThatShouldRunOnEmpty.length && !this._isFormOrFieldset; + + if (shouldHaltValidationOnEmpty) { + this.__finishValidationPass({ + syncValidationResult: this.__syncValidationResult, + }); + return; + } } - // Separate Validators in sync and async - const /** @type {Validator[]} */ filteredValidators = this._allValidators.filter( - v => !(v instanceof ResultValidator) && !(v instanceof Required), - ); - const /** @type {Validator[]} */ syncValidators = filteredValidators.filter(v => { - const vCtor = /** @type {typeof Validator} */ (v.constructor); - return !vCtor.async; - }); - const /** @type {Validator[]} */ asyncValidators = filteredValidators.filter(v => { - const vCtor = /** @type {typeof Validator} */ (v.constructor); - return vCtor.async; - }); + const metaValidators = /** @type {MetaValidator[]} */ []; + const syncValidators = /** @type {Validator[]} */ []; + const asyncValidators = /** @type {Validator[]} */ []; + + for (const v of this._allValidators) { + if (v instanceof MetaValidator) { + metaValidators.push(v); + } else if (v instanceof Required) { + // syncValidators.unshift(v); + // Required validator was already handled + } else if (/** @type {typeof Validator} */ (v.constructor).async) { + asyncValidators.push(v); + } else { + syncValidators.push(v); + } + } + + const hasAsync = Boolean(asyncValidators.length); /** * 2. Synchronous validators */ - this.__executeSyncValidators(syncValidators, value, { - hasAsync: Boolean(asyncValidators.length), + + this.__syncValidationResult = [ + // This could eventually contain the Required Validator + ...this.__syncValidationResult, + ...this.__executeSyncValidators(syncValidators, value), + ]; + // Finish the first (synchronous) pass + this.__finishValidationPass({ + syncValidationResult: this.__syncValidationResult, + metaValidators, }); /** * 3. Asynchronous validators */ - await this.__executeAsyncValidators(asyncValidators, value); + if (hasAsync) { + // Add a hint in the ui that we're waiting for async validation + this.isPending = true; + this.__asyncValidationResult = await this.__executeAsyncValidators(asyncValidators, value); + this.isPending = false; + // Now finish the second (asynchronous) pass (including both sync and async results) + this.__finishValidationPass({ + syncValidationResult: this.__syncValidationResult, + asyncValidationResult: this.__asyncValidationResult, + metaValidators, + }); + this.__validateCompleteResolve?.(true); + } else { + this.__validateCompleteResolve?.(true); + } } /** - * step a2 (as explained in `validate()`): calls `__finishValidation` + * step a2 (as explained in `validate()`): calls `__finishValidationPass` * @param {Validator[]} syncValidators * @param {unknown} value - * @param {{ hasAsync: boolean }} opts * @private */ - __executeSyncValidators(syncValidators, value, { hasAsync }) { - if (syncValidators.length) { - this.__syncValidationResult = syncValidators - .map(v => ({ - validator: v, - // TODO: fix this type - ts things this is not a FormControlHost? - // @ts-ignore - outcome: /** @type {boolean|string} */ (v.execute(value, v.param, { node: this })), - })) - .filter(v => Boolean(v.outcome)); - } - this.__finishValidation({ source: 'sync', hasAsync }); + __executeSyncValidators(syncValidators, value) { + return syncValidators + .map(v => ({ + validator: v, + // TODO: fix this type - ts things this is not a FormControlHost? + // @ts-ignore + outcome: /** @type {boolean|string} */ (v.execute(value, v.param, { node: this })), + })) + .filter(v => Boolean(v.outcome)); } /** - * step a3 (as explained in `validate()`), calls __finishValidation - * @param {Validator[]} asyncValidators all Validators except required and ResultValidators + * step a3 (as explained in `validate()`), calls __finishValidationPass + * @param {Validator[]} asyncValidators all Validators except required and MetaValidators * @param {?} value * @private */ async __executeAsyncValidators(asyncValidators, value) { - if (asyncValidators.length) { - this.isPending = true; - const resultPromises = asyncValidators.map(v => v.execute(value, v.param, { node: this })); - const asyncExecutionResults = await Promise.all(resultPromises); + const outcomePromises = asyncValidators.map(v => v.execute(value, v.param, { node: this })); + const asyncExecutionResults = await Promise.all(outcomePromises); - this.__asyncValidationResult = asyncExecutionResults - .map((r, i) => ({ - validator: asyncValidators[i], - outcome: /** @type {boolean|string} */ (asyncExecutionResults[i]), - })) - .filter(v => Boolean(v.outcome)); - - this.__finishValidation({ source: 'async' }); - this.isPending = false; - } + return asyncExecutionResults + .map((r, i) => ({ + validator: asyncValidators[i], + outcome: /** @type {boolean|string} */ (asyncExecutionResults[i]), + })) + .filter(v => Boolean(v.outcome)); } /** - * step b (as explained in `validate()`), called by __finishValidation + * step b (as explained in `validate()`), called by __finishValidationPass * @param {{validator: Validator;outcome: boolean | string;}[]} regularValidationResult result of steps 1-3 + * @param {MetaValidator[]} metaValidators * @private */ - __executeResultValidators(regularValidationResult) { - const resultValidators = /** @type {ResultValidator[]} */ ( - this._allValidators.filter(v => { - const vCtor = /** @type {typeof Validator} */ (v.constructor); - return !vCtor.async && v instanceof ResultValidator; - }) - ); - - if (!resultValidators.length) { + __executeMetaValidators(regularValidationResult, metaValidators) { + if (!metaValidators.length) { return []; } @@ -529,7 +562,7 @@ export const ValidateMixinImplementation = superclass => } // Map everything to Validator[] for backwards compatibility - return resultValidators + return metaValidators .map(v => ({ validator: v, outcome: /** @type {boolean|string} */ ( @@ -546,19 +579,35 @@ export const ValidateMixinImplementation = superclass => } /** + * A 'pass' is a single run of the validation process, which will be triggered in these cases: + * - on clear or disable + * - on sync validation + * - on async validation (can depend on server response) + * + * This method inishes a pass by adding the properties to the instance: + * - validationStates + * - hasFeedbackFor + * + * It sends a private event validate-performed, which is received by parent Formgroups. + * * @param {object} options - * @param {'sync'|'async'} options.source - * @param {boolean} [options.hasAsync] whether async validators are configured in this run. + * @param {ValidationResultEntry[]} [options.syncValidationResult] + * @param {ValidationResultEntry[]} [options.asyncValidationResult] + * @param {MetaValidator[]} [options.metaValidators] MetaValidators to be executed * @private * If not, we have nothing left to wait for. */ - __finishValidation({ source, hasAsync }) { - const syncAndAsyncOutcome = [...this.__syncValidationResult, ...this.__asyncValidationResult]; - // if we have any ResultValidators left, now is the time to run them... - const resultOutCome = /** @type {ValidationResultEntry[]} */ ( - this.__executeResultValidators(syncAndAsyncOutcome) + __finishValidationPass({ + syncValidationResult = [], + asyncValidationResult = [], + metaValidators = [], + } = {}) { + const syncAndAsyncOutcome = [...syncValidationResult, ...asyncValidationResult]; + // if we have any MetaValidators left, now is the time to run them... + const metaOutCome = /** @type {ValidationResultEntry[]} */ ( + this.__executeMetaValidators(syncAndAsyncOutcome, metaValidators) ); - this.__validationResult = [...resultOutCome, ...syncAndAsyncOutcome]; + this.__validationResult = [...metaOutCome, ...syncAndAsyncOutcome]; const ctor = /** @type {ValidateHostConstructor} */ (this.constructor); @@ -567,13 +616,13 @@ export const ValidateMixinImplementation = superclass => (acc, type) => ({ ...acc, [type]: {} }), {}, ); - this.__validationResult.forEach(({ validator, outcome }) => { + for (const { validator, outcome } of this.__validationResult) { if (!validationStates[validator.type]) { validationStates[validator.type] = {}; } const vCtor = /** @type {typeof Validator} */ (validator.constructor); validationStates[validator.type][vCtor.validatorName] = outcome; - }); + } this.validationStates = validationStates; this.hasFeedbackFor = [ @@ -581,11 +630,6 @@ export const ValidateMixinImplementation = superclass => ]; /** private event that should be listened to by LionFieldSet */ this.dispatchEvent(new Event('validate-performed', { bubbles: true })); - if (source === 'async' || !hasAsync) { - if (this.__validateCompleteResolve) { - this.__validateCompleteResolve(true); - } - } } /** @@ -611,51 +655,49 @@ export const ValidateMixinImplementation = superclass => */ __setupValidators() { const events = ['param-changed', 'config-changed']; - if (this.__prevValidators) { - this.__prevValidators.forEach(v => { - events.forEach(e => { - if (v.removeEventListener) { - v.removeEventListener(e, this._onValidatorUpdated); - } - }); - v.onFormControlDisconnect( - /** @type {import('../../types/FormControlMixinTypes.js').FormControlHost} */ ( - /** @type {unknown} */ (this) - ), - ); - }); + + for (const validatorToCleanup of this.__prevValidators || []) { + for (const eventToCleanup of events) { + validatorToCleanup.removeEventListener?.(eventToCleanup, this._onValidatorUpdated); + } + validatorToCleanup.onFormControlDisconnect( + /** @type {import('../../types/FormControlMixinTypes.js').FormControlHost} */ ( + /** @type {unknown} */ (this) + ), + ); } - this._allValidators.forEach(v => { - if (!(v instanceof Validator)) { + + for (const validatorToSetup of this._allValidators) { + if (!(validatorToSetup instanceof Validator)) { // throws in constructor are not visible to end user so we do both - const errorType = Array.isArray(v) ? 'array' : typeof v; - const errorMessage = `Validators array only accepts class instances of Validator. Type "${errorType}" found. This may be caused by having multiple installations of @lion/form-core.`; + const errorType = Array.isArray(validatorToSetup) ? 'array' : typeof validatorToSetup; + const errorMessage = `Validators array only accepts class instances of Validator. Type "${errorType}" found. This may be caused by having multiple installations of "@lion/ui/form-core.js".`; // eslint-disable-next-line no-console console.error(errorMessage, this); throw new Error(errorMessage); } const ctor = /** @type {ValidateHostConstructor} */ (this.constructor); - if (ctor.validationTypes.indexOf(v.type) === -1) { - const vCtor = /** @type {typeof Validator} */ (v.constructor); + const vCtor = /** @type {typeof Validator} */ (validatorToSetup.constructor); + if (ctor.validationTypes.indexOf(validatorToSetup.type) === -1) { // throws in constructor are not visible to end user so we do both - const errorMessage = `This component does not support the validator type "${v.type}" used in "${vCtor.validatorName}". You may change your validators type or add it to the components "static get validationTypes() {}".`; + const errorMessage = `This component does not support the validator type "${validatorToSetup.type}" used in "${vCtor.validatorName}". You may change your validators type or add it to the components "static get validationTypes() {}".`; // eslint-disable-next-line no-console console.error(errorMessage, this); throw new Error(errorMessage); } - /** Updated the code to fix issue #1607 to sync the calendar date with validators params - * Here _onValidatorUpdated is responsible for responding to the event + + /** + * Updated the code to fix issue #1607 to sync the calendar date with validators params + * Here _onValidatorUpdated is responsible for responding to the event */ - events.forEach(eventName => { - if (v.addEventListener) { - v.addEventListener(eventName, e => { - // @ts-ignore for making validator param dynamic - this._onValidatorUpdated(e, { validator: v }); - }); - } - }); - v.onFormControlConnect(this); - }); + for (const eventToSetup of events) { + validatorToSetup.addEventListener?.(eventToSetup, e => { + // @ts-ignore for making validator param dynamic + this._onValidatorUpdated(e, { validator: validatorToSetup }); + }); + } + validatorToSetup.onFormControlConnect(this); + } this.__prevValidators = this._allValidators; } @@ -759,7 +801,7 @@ export const ValidateMixinImplementation = superclass => } const messageMap = await this.__getFeedbackMessages(this.__prioritizedResult); - _feedbackNode.feedbackData = messageMap.length ? messageMap : []; + _feedbackNode.feedbackData = messageMap || []; }); } else { this.__feedbackQueue.add(async () => { @@ -827,12 +869,7 @@ export const ValidateMixinImplementation = superclass => * @protected */ _hasFeedbackVisibleFor(type) { - return ( - this.hasFeedbackFor && - this.hasFeedbackFor.includes(type) && - this.shouldShowFeedbackFor && - this.shouldShowFeedbackFor.includes(type) - ); + return this.hasFeedbackFor?.includes(type) && this.shouldShowFeedbackFor?.includes(type); } /** diff --git a/packages/ui/components/form-core/test-suites/ValidateMixin.suite.js b/packages/ui/components/form-core/test-suites/ValidateMixin.suite.js index 698c926e3..9fbfd7832 100644 --- a/packages/ui/components/form-core/test-suites/ValidateMixin.suite.js +++ b/packages/ui/components/form-core/test-suites/ValidateMixin.suite.js @@ -77,7 +77,7 @@ export function runValidateMixinSuite(customConfig) { const stub = sinon.stub(console, 'error'); const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}>`)); const errorMessage = - 'Validators array only accepts class instances of Validator. Type "array" found. This may be caused by having multiple installations of @lion/form-core.'; + 'Validators array only accepts class instances of Validator. Type "array" found. This may be caused by having multiple installations of "@lion/ui/form-core.js".'; expect(() => { // @ts-expect-error putting the wrong value on purpose el.validators = [[new Required()]]; @@ -85,7 +85,7 @@ export function runValidateMixinSuite(customConfig) { expect(stub.args[0][0]).to.equal(errorMessage); const errorMessage2 = - 'Validators array only accepts class instances of Validator. Type "string" found. This may be caused by having multiple installations of @lion/form-core.'; + 'Validators array only accepts class instances of Validator. Type "string" found. This may be caused by having multiple installations of "@lion/ui/form-core.js".'; expect(() => { // @ts-expect-error because we purposely put a wrong type el.validators = ['required']; @@ -322,7 +322,7 @@ export function runValidateMixinSuite(customConfig) { // @ts-ignore [allow-private] in test const syncSpy = sinon.spy(el, '__executeSyncValidators'); // @ts-ignore [allow-private] in test - const resultSpy2 = sinon.spy(el, '__executeResultValidators'); + const resultSpy2 = sinon.spy(el, '__executeMetaValidators'); el.modelValue = 'nonEmpty'; expect(syncSpy.calledBefore(resultSpy2)).to.be.true; @@ -337,7 +337,7 @@ export function runValidateMixinSuite(customConfig) { // @ts-ignore [allow-private] in test const asyncSpy = sinon.spy(el, '__executeAsyncValidators'); // @ts-ignore [allow-private] in test - const resultSpy = sinon.spy(el, '__executeResultValidators'); + const resultSpy = sinon.spy(el, '__executeMetaValidators'); el.modelValue = 'nonEmpty'; expect(resultSpy.callCount).to.equal(1); @@ -855,6 +855,34 @@ export function runValidateMixinSuite(customConfig) { expect(alwaysInvalidSpy.callCount).to.equal(1); // __isRequired returned true (valid) }); + it('does not prevent other Validators from being called when input is empty, but at least one Validator has "executesOnEmpty"', async () => { + class AlwaysInvalidExecutingOnEmpty extends Validator { + static validatorName = 'AlwaysInvalidExecutingOnEmpty'; + + static executesOnEmpty = true; + + execute() { + return true; + } + } + const alwaysInvalidExecutingOnEmpty = new AlwaysInvalidExecutingOnEmpty(); + const aalwaysInvalidExecutingOnEmptySpy = sinon.spy( + alwaysInvalidExecutingOnEmpty, + 'execute', + ); + const el = /** @type {ValidateElement} */ ( + await fixture(html` + <${tag} + .validators=${[new Required(), alwaysInvalidExecutingOnEmpty]} + .modelValue=${''} + >${lightDom} + `) + ); + expect(aalwaysInvalidExecutingOnEmptySpy.callCount).to.equal(1); + el.modelValue = 'foo'; + expect(aalwaysInvalidExecutingOnEmptySpy.callCount).to.equal(2); + }); + it('adds [aria-required="true"] to "._inputNode"', async () => { const el = /** @type {ValidateElement} */ ( await fixture(html` diff --git a/packages/ui/components/form-core/test-suites/form-group/FormGroupMixin-input.suite.js b/packages/ui/components/form-core/test-suites/form-group/FormGroupMixin-input.suite.js index 8b41a90e0..c96a72593 100644 --- a/packages/ui/components/form-core/test-suites/form-group/FormGroupMixin-input.suite.js +++ b/packages/ui/components/form-core/test-suites/form-group/FormGroupMixin-input.suite.js @@ -313,7 +313,7 @@ export function runFormGroupMixinInputSuite(cfg = {}) { }); // TODO: wait for updated on disconnected to be fixed: https://github.com/lit/lit/issues/1901 - it.skip(`cleans up feedback message belonging to fieldset on disconnect`, async () => { + it.skip(`cleans up feedback message belonging to fieldset on disconnect`, async () => { const el = await childAriaFixture('feedback'); await childAriaTest(el, { cleanupPhase: true }); }); diff --git a/packages/ui/components/form-core/test-suites/form-group/FormGroupMixin.suite.js b/packages/ui/components/form-core/test-suites/form-group/FormGroupMixin.suite.js index 13ef9e4e1..ec01adb7f 100644 --- a/packages/ui/components/form-core/test-suites/form-group/FormGroupMixin.suite.js +++ b/packages/ui/components/form-core/test-suites/form-group/FormGroupMixin.suite.js @@ -1,5 +1,5 @@ import { LitElement } from 'lit'; -import { IsNumber, LionField, Validator, FormGroupMixin } from '@lion/ui/form-core.js'; +import { IsNumber, Required, LionField, Validator, FormGroupMixin } from '@lion/ui/form-core.js'; import '@lion/ui/define/lion-field.js'; import '@lion/ui/define/lion-validation-feedback.js'; @@ -577,6 +577,25 @@ export function runFormGroupMixinSuite(cfg = {}) { expect(el.validationStates.error).to.deep.equal({}); }); + it('works with Required', async () => { + const el = /** @type {FormGroup} */ ( + await fixture(html` + <${tag} name="myGroup" .validators="${[new Required()]}"> + <${childTag} name="fieldA"> + <${childTag} name="fieldB"> + + `) + ); + // initially the group is invalid + expect(el.validationStates.error.Required).to.be.true; + el.formElements.fieldA.modelValue = 'foo'; + // If at least one child is filled, the group is valid + expect(el.validationStates.error.Required).to.be.undefined; + // // initially the group is invalid + el.formElements.fieldA.modelValue = ''; + expect(el.validationStates.error.Required).to.be.true; + }); + it('validates on children (de)registration', async () => { class HasEvenNumberOfChildren extends Validator { static get validatorName() { @@ -1129,9 +1148,7 @@ export function runFormGroupMixinSuite(cfg = {}) { it('has correct validation afterwards', async () => { class IsCat extends Validator { - static get validatorName() { - return 'IsCat'; - } + static validatorName = 'IsCat'; /** * @param {string} value @@ -1142,9 +1159,7 @@ export function runFormGroupMixinSuite(cfg = {}) { } } class ColorContainsA extends Validator { - static get validatorName() { - return 'ColorContainsA'; - } + static validatorName = 'ColorContainsA'; /** * @param {{ [x:string]:any }} value