diff --git a/checkbox/internal/checkbox.ts b/checkbox/internal/checkbox.ts index 89761ce465..1298ea770a 100644 --- a/checkbox/internal/checkbox.ts +++ b/checkbox/internal/checkbox.ts @@ -27,6 +27,7 @@ import { getFormValue, mixinFormAssociated, } from '../../labs/behaviors/form-associated.js'; +import {CheckboxValidator} from '../../labs/behaviors/validators/checkbox-validator.js'; // Separate variable needed for closure. const checkboxBaseClass = mixinFormAssociated( @@ -123,7 +124,7 @@ export class Checkbox extends checkboxBaseClass { @query('input') private readonly input!: HTMLInputElement | null; // Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432 // Replace with this[internals].validity.customError when resolved. - private hasCustomValidityError = false; + private customValidityError = ''; constructor() { super(); @@ -183,8 +184,8 @@ export class Checkbox extends checkboxBaseClass { * @param error The error message to display. */ setCustomValidity(error: string) { - this.hasCustomValidityError = !!error; - this[internals].setValidity({customError: !!error}, error, this.getInput()); + this.customValidityError = error; + this.syncValidity(); } protected override update(changed: PropertyValues) { @@ -266,39 +267,17 @@ export class Checkbox extends checkboxBaseClass { } private syncValidity() { - // Sync the internal 's validity and the host's ElementInternals - // validity. We do this to re-use native `` validation messages. - const input = this.getInput(); - if (this.hasCustomValidityError) { - input.setCustomValidity(this[internals].validationMessage); - } else { - input.setCustomValidity(''); - } - + const {validity, validationMessage} = this.validator.getValidity(); this[internals].setValidity( - input.validity, - input.validationMessage, - this.getInput(), + { + ...validity, + customError: !!this.customValidityError, + }, + this.customValidityError || validationMessage, + this.input ?? undefined, ); } - private getInput() { - if (!this.input) { - // If the input is not yet defined, synchronously render. - this.connectedCallback(); - this.performUpdate(); - } - - if (this.isUpdatePending) { - // If there are pending updates, synchronously perform them. This ensures - // that constraint validation properties (like `required`) are synced - // before interacting with input APIs that depend on them. - this.scheduleUpdate(); - } - - return this.input!; - } - // Writable mixin properties for lit-html binding, needed for lit-analyzer declare disabled: boolean; declare name: string; @@ -324,4 +303,6 @@ export class Checkbox extends checkboxBaseClass { override formStateRestoreCallback(state: string) { this.checked = state === 'true'; } + + private readonly validator = new CheckboxValidator(() => this); } diff --git a/labs/behaviors/validators/checkbox-validator.ts b/labs/behaviors/validators/checkbox-validator.ts new file mode 100644 index 0000000000..c0f9a2d371 --- /dev/null +++ b/labs/behaviors/validators/checkbox-validator.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Validator} from './validator.js'; + +/** + * Constraint validation properties for a checkbox. + */ +export interface CheckboxState { + /** + * Whether the checkbox is checked. + */ + checked: boolean; + + /** + * Whether the checkbox is required. + */ + required: boolean; +} + +/** + * A validator that provides constraint validation that emulates + * `` validation. + */ +export class CheckboxValidator extends Validator { + private checkboxControl?: HTMLInputElement; + + protected override computeValidity(state: CheckboxState) { + if (!this.checkboxControl) { + // Lazily create the platform input + this.checkboxControl = document.createElement('input'); + this.checkboxControl.type = 'checkbox'; + } + + this.checkboxControl.checked = state.checked; + this.checkboxControl.required = state.required; + return { + validity: this.checkboxControl.validity, + validationMessage: this.checkboxControl.validationMessage, + }; + } + + protected override equals(prev: CheckboxState, next: CheckboxState) { + return prev.checked === next.checked && prev.required === next.required; + } + + protected override copy({checked, required}: CheckboxState): CheckboxState { + return {checked, required}; + } +} diff --git a/labs/behaviors/validators/checkbox-validator_test.ts b/labs/behaviors/validators/checkbox-validator_test.ts new file mode 100644 index 0000000000..86399d10ec --- /dev/null +++ b/labs/behaviors/validators/checkbox-validator_test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {CheckboxValidator} from './checkbox-validator.js'; + +describe('CheckboxValidator', () => { + it('is invalid when required and not checked', () => { + const state = { + required: true, + checked: false, + }; + + const validator = new CheckboxValidator(() => state); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeTrue(); + expect(validationMessage).withContext('validationMessage').not.toBe(''); + }); + + it('is valid when required and checked', () => { + const state = { + required: true, + checked: true, + }; + + const validator = new CheckboxValidator(() => state); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeFalse(); + expect(validationMessage).withContext('validationMessage').toBe(''); + }); + + it('is valid when not required', () => { + const state = { + required: false, + checked: false, + }; + + const validator = new CheckboxValidator(() => state); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeFalse(); + expect(validationMessage).withContext('validationMessage').toBe(''); + }); +}); diff --git a/labs/behaviors/validators/validator.ts b/labs/behaviors/validators/validator.ts new file mode 100644 index 0000000000..015e34d06c --- /dev/null +++ b/labs/behaviors/validators/validator.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A class that computes and caches `ValidityStateFlags` for a component with + * a given `State` interface. + * + * Cached performance before computing validity is important since constraint + * validation must be checked frequently and synchronously when properties + * change. + * + * @template State The expected interface of properties relevant to constraint + * validation. + */ +export abstract class Validator { + /** + * The previous state, used to determine if state changed and validation needs + * to be re-computed. + */ + private prevState?: State; + + /** + * The current validity state and message. This is cached and returns if + * constraint validation state does not change. + */ + private currentValidity: ValidityAndMessage = { + validity: {}, + validationMessage: '', + }; + + /** + * Creates a new validator. + * + * @param getCurrentState A callback that returns the current state of + * constraint validation-related properties. + */ + constructor(private readonly getCurrentState: () => State) {} + + /** + * Returns the current `ValidityStateFlags` and validation message for the + * validator. + * + * If the constraint validation state has not changed, this will return a + * cached result. This is important since `getValidity()` can be called + * frequently in response to synchronous property changes. + * + * @return The current validity and validation message. + */ + getValidity(): ValidityAndMessage { + const state = this.getCurrentState(); + const hasStateChanged = + !this.prevState || !this.equals(this.prevState, state); + if (!hasStateChanged) { + return this.currentValidity; + } + + const {validity, validationMessage} = this.computeValidity(state); + this.prevState = this.copy(state); + this.currentValidity = { + validationMessage, + validity: { + // Change any `ValidityState` instances into `ValidityStateFlags` since + // `ValidityState` cannot be easily `{...spread}`. + badInput: validity.badInput, + customError: validity.customError, + patternMismatch: validity.patternMismatch, + rangeOverflow: validity.rangeOverflow, + rangeUnderflow: validity.rangeUnderflow, + stepMismatch: validity.stepMismatch, + tooLong: validity.tooLong, + tooShort: validity.tooShort, + typeMismatch: validity.typeMismatch, + valueMissing: validity.valueMissing, + }, + }; + + return this.currentValidity; + } + + /** + * Computes the `ValidityStateFlags` and validation message for a given set + * of constraint validation properties. + * + * Implementations can use platform elements like `` and `'s validity and the host's ElementInternals - // validity. We do this to re-use native `` validation messages. - const input = this.getInput(); - if (this.hasCustomValidityError) { - input.setCustomValidity(this[internals].validationMessage); - } else { - input.setCustomValidity(''); - } - + const {validity, validationMessage} = this.validator.getValidity(); this[internals].setValidity( - input.validity, - input.validationMessage, - this.getInput(), + { + ...validity, + customError: !!this.customValidityError, + }, + this.customValidityError || validationMessage, + this.input ?? undefined, ); } - private getInput() { - if (!this.input) { - // If the input is not yet defined, synchronously render. - this.connectedCallback(); - this.performUpdate(); - } - - if (this.isUpdatePending) { - // If there are pending updates, synchronously perform them. This ensures - // that constraint validation properties (like `required`) are synced - // before interacting with input APIs that depend on them. - this.scheduleUpdate(); - } - - return this.input!; - } - // Writable mixin properties for lit-html binding, needed for lit-analyzer declare disabled: boolean; declare name: string; @@ -335,4 +314,9 @@ export class Switch extends switchBaseClass { override formStateRestoreCallback(state: string) { this.selected = state === 'true'; } + + private readonly validator = new CheckboxValidator(() => ({ + checked: this.selected, + required: this.required, + })); }