diff --git a/.eslintrc.json b/.eslintrc.json index f2b23dc9e2..74bbe1286a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -61,10 +61,21 @@ }, "overrides": [ { - // Prevent using optional-chaining in source files, as it is not supported by Polymer analyzer "files": ["packages/*/src/**/*.js"], "rules": { - "es/no-optional-chaining": "error" + // Prevent using optional-chaining in source files, as it is not supported by Polymer analyzer + "es/no-optional-chaining": "error", + "no-restricted-syntax": [ + "error", + { + "selector": "ForInStatement", + "message": "for..in loops are slower than Object.{keys,values,entries} and have their caveats." + }, + { + "selector": "CallExpression[callee.property.name='validate']", + "message": "Don't call validate() directly - it bypasses manual validation mode. Use _requestValidation() instead" + } + ] } }, { diff --git a/packages/checkbox-group/src/vaadin-checkbox-group-mixin.js b/packages/checkbox-group/src/vaadin-checkbox-group-mixin.js index db70b40dc4..8af84155e5 100644 --- a/packages/checkbox-group/src/vaadin-checkbox-group-mixin.js +++ b/packages/checkbox-group/src/vaadin-checkbox-group-mixin.js @@ -264,7 +264,7 @@ export const CheckboxGroupMixin = (superclass) => }); if (oldValue !== undefined) { - this.validate(); + this._requestValidation(); } } @@ -304,7 +304,7 @@ export const CheckboxGroupMixin = (superclass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (!focused && document.hasFocus()) { - this.validate(); + this._requestValidation(); } } }; diff --git a/packages/checkbox/src/vaadin-checkbox-mixin.js b/packages/checkbox/src/vaadin-checkbox-mixin.js index 41bca02b79..bf9997c695 100644 --- a/packages/checkbox/src/vaadin-checkbox-mixin.js +++ b/packages/checkbox/src/vaadin-checkbox-mixin.js @@ -222,14 +222,14 @@ export const CheckboxMixin = (superclass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (!focused && document.hasFocus()) { - this.validate(); + this._requestValidation(); } } /** @private */ _checkedChanged(checked) { if (checked || this.__oldChecked) { - this.validate(); + this._requestValidation(); } this.__oldChecked = checked; @@ -246,7 +246,7 @@ export const CheckboxMixin = (superclass) => super._requiredChanged(required); if (required === false) { - this.validate(); + this._requestValidation(); } } diff --git a/packages/combo-box/src/vaadin-combo-box-light-mixin.js b/packages/combo-box/src/vaadin-combo-box-light-mixin.js index b756ebf305..bd731c5a23 100644 --- a/packages/combo-box/src/vaadin-combo-box-light-mixin.js +++ b/packages/combo-box/src/vaadin-combo-box-light-mixin.js @@ -99,17 +99,6 @@ export const ComboBoxLightMixin = (superClass) => }); } - /** - * Returns true if the current input value satisfies all constraints (if any). - * @return {boolean} - */ - checkValidity() { - if (this.inputElement && this.inputElement.validate) { - return this.inputElement.validate(); - } - return super.checkValidity(); - } - /** * @protected * @override diff --git a/packages/combo-box/src/vaadin-combo-box-mixin.js b/packages/combo-box/src/vaadin-combo-box-mixin.js index f01a788ded..bd2bfa94f8 100644 --- a/packages/combo-box/src/vaadin-combo-box-mixin.js +++ b/packages/combo-box/src/vaadin-combo-box-mixin.js @@ -1177,7 +1177,7 @@ export const ComboBoxMixin = (subclass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (document.hasFocus()) { - this.validate(); + this._requestValidation(); } if (this.value !== this._lastCommittedValue) { diff --git a/packages/custom-field/src/vaadin-custom-field-mixin.js b/packages/custom-field/src/vaadin-custom-field-mixin.js index 0e59b6ac17..190c4981b8 100644 --- a/packages/custom-field/src/vaadin-custom-field-mixin.js +++ b/packages/custom-field/src/vaadin-custom-field-mixin.js @@ -159,7 +159,7 @@ export const CustomFieldMixin = (superClass) => super._setFocused(focused); if (!focused) { - this.validate(); + this._requestValidation(); } } @@ -203,7 +203,7 @@ export const CustomFieldMixin = (superClass) => super._requiredChanged(required); if (required === false) { - this.validate(); + this._requestValidation(); } } @@ -233,7 +233,7 @@ export const CustomFieldMixin = (superClass) => event.stopPropagation(); this.__setValue(); - this.validate(); + this._requestValidation(); this.dispatchEvent( new CustomEvent('change', { bubbles: true, @@ -299,7 +299,7 @@ export const CustomFieldMixin = (superClass) => this.__applyInputsValue(value || '\t'); if (oldValue !== undefined) { - this.validate(); + this._requestValidation(); } } diff --git a/packages/date-picker/src/vaadin-date-picker-mixin.js b/packages/date-picker/src/vaadin-date-picker-mixin.js index 7a54b59502..03cb35a961 100644 --- a/packages/date-picker/src/vaadin-date-picker-mixin.js +++ b/packages/date-picker/src/vaadin-date-picker-mixin.js @@ -479,7 +479,7 @@ export const DatePickerMixin = (subclass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (document.hasFocus()) { - this.validate(); + this._requestValidation(); } } } @@ -624,13 +624,8 @@ export const DatePickerMixin = (subclass) => !this._selectedDate || dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled); let inputValidity = true; - if (this.inputElement) { - if (this.inputElement.checkValidity) { - inputValidity = this.inputElement.checkValidity(); - } else if (this.inputElement.validate) { - // Iron-form-elements have the validate API - inputValidity = this.inputElement.validate(); - } + if (this.inputElement && this.inputElement.checkValidity) { + inputValidity = this.inputElement.checkValidity(); } return inputValid && isDateValid && inputValidity; @@ -717,10 +712,10 @@ export const DatePickerMixin = (subclass) => const unparsableValue = this.__unparsableValue; if (this.__committedValue !== this.value) { - this.validate(); + this._requestValidation(); this.dispatchEvent(new CustomEvent('change', { bubbles: true })); } else if (this.__committedUnparsableValue !== unparsableValue) { - this.validate(); + this._requestValidation(); this.dispatchEvent(new CustomEvent('unparsable-change')); } @@ -849,7 +844,7 @@ export const DatePickerMixin = (subclass) => if (oldValue !== undefined) { // Validate only if `value` changes after initialization. - this.validate(); + this._requestValidation(); } } } else { @@ -1015,7 +1010,7 @@ export const DatePickerMixin = (subclass) => // Needed in case the value was not changed: open and close dropdown, // especially on outside click. On Esc key press, do not validate. if (!this.value && !this._keyboardActive) { - this.validate(); + this._requestValidation(); } } diff --git a/packages/date-time-picker/src/vaadin-date-time-picker-mixin.js b/packages/date-time-picker/src/vaadin-date-time-picker-mixin.js index 40efefd16c..24e2a84b33 100644 --- a/packages/date-time-picker/src/vaadin-date-time-picker-mixin.js +++ b/packages/date-time-picker/src/vaadin-date-time-picker-mixin.js @@ -370,7 +370,7 @@ export const DateTimePickerMixin = (superClass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (!focused && document.hasFocus()) { - this.validate(); + this._requestValidation(); } } @@ -414,7 +414,7 @@ export const DateTimePickerMixin = (superClass) => event.stopPropagation(); if (this.__dispatchChangeForValue === this.value) { - this.validate(); + this._requestValidation(); this.__dispatchChange(); } this.__dispatchChangeForValue = undefined; @@ -473,7 +473,7 @@ export const DateTimePickerMixin = (superClass) => newDatePicker.max = this.__formatDateISO(this.__maxDateTime, this.__defaultDateMinMaxValue); // Disable default internal validation for the component - newDatePicker.validate = () => {}; + newDatePicker.manualValidation = true; } /** @private */ @@ -501,7 +501,7 @@ export const DateTimePickerMixin = (superClass) => this.__updateTimePickerMinMax(); // Disable default internal validation for the component - newTimePicker.validate = () => {}; + newTimePicker.manualValidation = true; } /** @private */ @@ -603,7 +603,7 @@ export const DateTimePickerMixin = (superClass) => } if (this.__oldRequired && !required) { - this.validate(); + this._requestValidation(); } this.__oldRequired = required; @@ -796,7 +796,7 @@ export const DateTimePickerMixin = (superClass) => this.__updateTimePickerMinMax(); if (this.__datePicker && this.__timePicker && this.value) { - this.validate(); + this._requestValidation(); } } @@ -809,7 +809,7 @@ export const DateTimePickerMixin = (superClass) => this.__updateTimePickerMinMax(); if (this.__datePicker && this.__timePicker && this.value) { - this.validate(); + this._requestValidation(); } } @@ -910,7 +910,7 @@ export const DateTimePickerMixin = (superClass) => // run initial validation here. Lit version runs observers differently and // this observer is executed first - ignore it to prevent validating twice. if ((this.min && this.__minDateTime) || (this.max && this.__maxDateTime)) { - this.validate(); + this._requestValidation(); } } } diff --git a/packages/field-base/src/input-constraints-mixin.js b/packages/field-base/src/input-constraints-mixin.js index bb282757be..554905d699 100644 --- a/packages/field-base/src/input-constraints-mixin.js +++ b/packages/field-base/src/input-constraints-mixin.js @@ -90,8 +90,8 @@ export const InputConstraintsMixin = dedupingMixin( const isLastConstraintRemoved = this.__previousHasConstraints && !hasConstraints; if ((this._hasValue || this.invalid) && hasConstraints) { - this.validate(); - } else if (isLastConstraintRemoved) { + this._requestValidation(); + } else if (isLastConstraintRemoved && !this.manualValidation) { this._setInvalid(false); } @@ -109,7 +109,7 @@ export const InputConstraintsMixin = dedupingMixin( _onChange(event) { event.stopPropagation(); - this.validate(); + this._requestValidation(); this.dispatchEvent( new CustomEvent('change', { diff --git a/packages/field-base/src/input-field-mixin.js b/packages/field-base/src/input-field-mixin.js index c62a173339..1c3a07da10 100644 --- a/packages/field-base/src/input-field-mixin.js +++ b/packages/field-base/src/input-field-mixin.js @@ -97,7 +97,7 @@ export const InputFieldMixin = (superclass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (!focused && document.hasFocus()) { - this.validate(); + this._requestValidation(); } } @@ -112,7 +112,7 @@ export const InputFieldMixin = (superclass) => super._onInput(event); if (this.invalid) { - this.validate(); + this._requestValidation(); } } @@ -133,7 +133,7 @@ export const InputFieldMixin = (superclass) => } if (this.invalid) { - this.validate(); + this._requestValidation(); } } }; diff --git a/packages/field-base/src/validate-mixin.d.ts b/packages/field-base/src/validate-mixin.d.ts index aaf12e5af3..1dbfb86617 100644 --- a/packages/field-base/src/validate-mixin.d.ts +++ b/packages/field-base/src/validate-mixin.d.ts @@ -16,6 +16,19 @@ export declare class ValidateMixinClass { */ invalid: boolean; + /** + * Set to true to enable manual validation mode. This mode disables automatic + * constraint validation, allowing you to control the validation process yourself. + * You can still trigger constraint validation manually with the `validate()` method + * or use `checkValidity()` to assess the component's validity without affecting + * the invalid state. In manual validation mode, you can also manipulate + * the `invalid` property directly through your application logic without conflicts + * with the component's internal validation. + * + * @attr {boolean} manual-validation + */ + manualValidation: boolean; + /** * Specifies that the user must fill in a value. */ @@ -30,4 +43,6 @@ export declare class ValidateMixinClass { * Returns true if the field value satisfies all constraints (if any). */ checkValidity(): boolean; + + protected _requestValidation(): void; } diff --git a/packages/field-base/src/validate-mixin.js b/packages/field-base/src/validate-mixin.js index 51a230fa18..2c2590c040 100644 --- a/packages/field-base/src/validate-mixin.js +++ b/packages/field-base/src/validate-mixin.js @@ -25,6 +25,22 @@ export const ValidateMixin = dedupingMixin( value: false, }, + /** + * Set to true to enable manual validation mode. This mode disables automatic + * constraint validation, allowing you to control the validation process yourself. + * You can still trigger constraint validation manually with the `validate()` method + * or use `checkValidity()` to assess the component's validity without affecting + * the invalid state. In manual validation mode, you can also manipulate + * the `invalid` property directly through your application logic without conflicts + * with the component's internal validation. + * + * @attr {boolean} manual-validation + */ + manualValidation: { + type: Boolean, + value: false, + }, + /** * Specifies that the user must fill in a value. */ @@ -79,6 +95,14 @@ export const ValidateMixin = dedupingMixin( return true; } + /** @protected */ + _requestValidation() { + if (!this.manualValidation) { + // eslint-disable-next-line no-restricted-syntax + this.validate(); + } + } + /** * Fired whenever the field is validated. * diff --git a/packages/field-base/test/input-constraints-mixin.test.js b/packages/field-base/test/input-constraints-mixin.test.js index 50d8e1f9e7..2a3f777d17 100644 --- a/packages/field-base/test/input-constraints-mixin.test.js +++ b/packages/field-base/test/input-constraints-mixin.test.js @@ -256,6 +256,46 @@ const runTests = (defineHelper, baseMixin) => { expect(changeSpy.firstCall.args[0].detail.sourceEvent).to.equal(event); }); }); + + describe('manual validation', () => { + beforeEach(async () => { + element = fixtureSync(`<${tag} manual-validation>`); + await nextRender(); + validateSpy = sinon.spy(element, 'validate'); + }); + + it('should not validate when adding constraints', async () => { + element.value = 'foo'; + element.required = true; + await nextUpdate(element); + expect(validateSpy).to.be.not.called; + }); + + it('should not validate when removing constraints', async () => { + element.value = 'foo'; + element.required = true; + element.minlength = 2; + await nextUpdate(element); + element.minlength = null; + await nextUpdate(element); + expect(validateSpy).to.be.not.called; + }); + + it('should not reset invalid when removing last constraint', async () => { + element.invalid = true; + element.required = true; + await nextUpdate(element); + element.required = false; + await nextUpdate(element); + expect(element.invalid).to.be.true; + }); + + it('should not validate on input change event', () => { + input.value = 'foo'; + fire(input, 'change'); + expect(validateSpy).to.be.not.called; + }); + }); }); describe('checkValidity', () => { diff --git a/packages/field-base/test/validate-mixin.test.js b/packages/field-base/test/validate-mixin.test.js index 5b20c98992..903ab25b56 100644 --- a/packages/field-base/test/validate-mixin.test.js +++ b/packages/field-base/test/validate-mixin.test.js @@ -32,6 +32,10 @@ const runTests = (defineHelper, baseMixin) => { expect(element.hasAttribute('invalid')).to.be.true; }); + it('should have manual validation disabled by default', () => { + expect(element.manualValidation).to.be.false; + }); + it('should fire invalid-changed event on invalid property change', async () => { const spy = sinon.spy(); element.addEventListener('invalid-changed', spy); @@ -44,6 +48,19 @@ const runTests = (defineHelper, baseMixin) => { await nextUpdate(element); expect(spy.calledOnce).to.be.true; }); + + it('should validate on _requestValidation() when manualValidation is false', () => { + const spy = sinon.spy(element, 'validate'); + element._requestValidation(); + expect(spy).to.be.calledOnce; + }); + + it('should not validate on _requestValidation() when manualValidation is true', () => { + const spy = sinon.spy(element, 'validate'); + element.manualValidation = true; + element._requestValidation(); + expect(spy).to.be.not.called; + }); }); describe('checkValidity', () => { @@ -66,6 +83,12 @@ const runTests = (defineHelper, baseMixin) => { element.value = 'value'; expect(element.checkValidity()).to.be.true; }); + + it('should still return result when manualValidation is true', () => { + element.manualValidation = true; + element.required = true; + expect(element.checkValidity()).to.be.false; + }); }); describe('validate', () => { @@ -116,6 +139,13 @@ const runTests = (defineHelper, baseMixin) => { expect(element.invalid).to.be.false; }); + it('should still validate when manualValidation is true', () => { + element.manualValidation = true; + element.required = true; + element.validate(); + expect(element.invalid).to.be.true; + }); + it('should fire a validated event on validation success', () => { const validatedSpy = sinon.spy(); element.addEventListener('validated', validatedSpy); diff --git a/packages/login/src/vaadin-login-form-mixin.js b/packages/login/src/vaadin-login-form-mixin.js index e4a5c05067..2b4ac3facb 100644 --- a/packages/login/src/vaadin-login-form-mixin.js +++ b/packages/login/src/vaadin-login-form-mixin.js @@ -50,7 +50,12 @@ export const LoginFormMixin = (superClass) => const userName = this.$.vaadinLoginUsername; const password = this.$.vaadinLoginPassword; - if (this.disabled || !(userName.validate() && password.validate())) { + // eslint-disable-next-line no-restricted-syntax + userName.validate(); + // eslint-disable-next-line no-restricted-syntax + password.validate(); + + if (this.disabled || userName.invalid || password.invalid) { return; } @@ -114,6 +119,7 @@ export const LoginFormMixin = (superClass) => const { currentTarget: inputActive } = e; const nextInput = inputActive.id === 'vaadinLoginUsername' ? this.$.vaadinLoginPassword : this.$.vaadinLoginUsername; + // eslint-disable-next-line no-restricted-syntax if (inputActive.validate()) { if (nextInput.checkValidity()) { this.submit(); diff --git a/packages/multi-select-combo-box/src/vaadin-multi-select-combo-box.js b/packages/multi-select-combo-box/src/vaadin-multi-select-combo-box.js index 4252d96dd5..071479d4bf 100644 --- a/packages/multi-select-combo-box/src/vaadin-multi-select-combo-box.js +++ b/packages/multi-select-combo-box/src/vaadin-multi-select-combo-box.js @@ -689,7 +689,7 @@ class MultiSelectComboBox extends ResizeMixin(InputControlMixin(ThemableMixin(El // losing focus, which happens on browser tab switch. if (!focused && document.hasFocus()) { this._focusedChipIndex = -1; - this.validate(); + this._requestValidation(); } } @@ -928,7 +928,7 @@ class MultiSelectComboBox extends ResizeMixin(InputControlMixin(ThemableMixin(El __updateSelection(selectedItems) { this.selectedItems = selectedItems; - this.validate(); + this._requestValidation(); this.dispatchEvent(new CustomEvent('change', { bubbles: true })); } diff --git a/packages/number-field/src/vaadin-number-field-mixin.js b/packages/number-field/src/vaadin-number-field-mixin.js index 86a7a254eb..231283bb46 100644 --- a/packages/number-field/src/vaadin-number-field-mixin.js +++ b/packages/number-field/src/vaadin-number-field-mixin.js @@ -511,10 +511,10 @@ export const NumberFieldMixin = (superClass) => */ __commitValueChange() { if (this.__committedValue !== this.value) { - this.validate(); + this._requestValidation(); this.dispatchEvent(new CustomEvent('change', { bubbles: true })); } else if (this.__committedUnparsableValueStatus !== this.__hasUnparsableValue) { - this.validate(); + this._requestValidation(); this.dispatchEvent(new CustomEvent('unparsable-change')); } diff --git a/packages/radio-group/src/vaadin-radio-group-mixin.js b/packages/radio-group/src/vaadin-radio-group-mixin.js index 21b3f1a1ae..2ee022d8b7 100644 --- a/packages/radio-group/src/vaadin-radio-group-mixin.js +++ b/packages/radio-group/src/vaadin-radio-group-mixin.js @@ -303,7 +303,7 @@ export const RadioGroupMixin = (superclass) => } if (oldValue !== undefined) { - this.validate(); + this._requestValidation(); } } @@ -380,7 +380,7 @@ export const RadioGroupMixin = (superclass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (!focused && document.hasFocus()) { - this.validate(); + this._requestValidation(); } } diff --git a/packages/select/src/vaadin-select-base-mixin.js b/packages/select/src/vaadin-select-base-mixin.js index a6dee6107b..1a9884d1fb 100644 --- a/packages/select/src/vaadin-select-base-mixin.js +++ b/packages/select/src/vaadin-select-base-mixin.js @@ -230,7 +230,7 @@ export const SelectBaseMixin = (superClass) => super._requiredChanged(required); if (required === false) { - this.validate(); + this._requestValidation(); } } @@ -294,7 +294,7 @@ export const SelectBaseMixin = (superClass) => // a change event is scheduled, as validation will be // triggered by `__dispatchChange()` in that case. if (oldValue !== undefined && !this.__dispatchChangePending) { - this.validate(); + this._requestValidation(); } } @@ -390,7 +390,7 @@ export const SelectBaseMixin = (superClass) => // will be triggered by `__dispatchChange()` in that case. // Also, skip validation when closed on Escape or Tab keys. if (!this.__dispatchChangePending && !this._keyboardActive) { - this.validate(); + this._requestValidation(); } } } @@ -596,7 +596,7 @@ export const SelectBaseMixin = (superClass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (!focused && document.hasFocus()) { - this.validate(); + this._requestValidation(); } } @@ -641,7 +641,7 @@ export const SelectBaseMixin = (superClass) => await this.updateComplete; } - this.validate(); + this._requestValidation(); this.dispatchEvent(new CustomEvent('change', { bubbles: true })); this.__dispatchChangePending = false; } diff --git a/packages/time-picker/src/vaadin-time-picker-mixin.js b/packages/time-picker/src/vaadin-time-picker-mixin.js index 38842d879b..6b9303885b 100644 --- a/packages/time-picker/src/vaadin-time-picker-mixin.js +++ b/packages/time-picker/src/vaadin-time-picker-mixin.js @@ -263,7 +263,7 @@ export const TimePickerMixin = (superClass) => // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (document.hasFocus()) { - this.validate(); + this._requestValidation(); } } } @@ -342,10 +342,10 @@ export const TimePickerMixin = (superClass) => const unparsableValue = this.__unparsableValue; if (this.__committedValue !== this.value) { - this.validate(); + this._requestValidation(); this.dispatchEvent(new CustomEvent('change', { bubbles: true })); } else if (this.__committedUnparsableValue !== unparsableValue) { - this.validate(); + this._requestValidation(); this.dispatchEvent(new CustomEvent('unparsable-change')); }