Skip to content

Commit

Permalink
refactor: add checkbox validator
Browse files Browse the repository at this point in the history
Part of adding constraint validation mixins. Each component that participates in constraint validation has different rules and error messages for validating. The `Validator` gives `mixinConstraintValidation()` a way to compute and cache validation.

Other validators will be added later, such as `SelectValidator` and `RadioValidator`

PiperOrigin-RevId: 583508468
  • Loading branch information
asyncLiz authored and copybara-github committed Nov 17, 2023
1 parent 302d181 commit 8eb1f30
Show file tree
Hide file tree
Showing 6 changed files with 378 additions and 64 deletions.
45 changes: 13 additions & 32 deletions checkbox/internal/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<Checkbox>) {
Expand Down Expand Up @@ -266,39 +267,17 @@ export class Checkbox extends checkboxBaseClass {
}

private syncValidity() {
// Sync the internal <input>'s validity and the host's ElementInternals
// validity. We do this to re-use native `<input>` 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;
Expand All @@ -324,4 +303,6 @@ export class Checkbox extends checkboxBaseClass {
override formStateRestoreCallback(state: string) {
this.checked = state === 'true';
}

private readonly validator = new CheckboxValidator(() => this);
}
53 changes: 53 additions & 0 deletions labs/behaviors/validators/checkbox-validator.ts
Original file line number Diff line number Diff line change
@@ -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
* `<input type="checkbox">` validation.
*/
export class CheckboxValidator extends Validator<CheckboxState> {
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};
}
}
47 changes: 47 additions & 0 deletions labs/behaviors/validators/checkbox-validator_test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
132 changes: 132 additions & 0 deletions labs/behaviors/validators/validator.ts
Original file line number Diff line number Diff line change
@@ -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<State> {
/**
* 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 `<input>` and `<select>` to
* sync state and compute validation along with i18n'd messages. This function
* may be expensive, and is only called when state changes.
*
* @param state The new state of constraint validation properties.
* @return An object containing a `validity` property with
* `ValidityStateFlags` and a `validationMessage` property.
*/
protected abstract computeValidity(state: State): ValidityAndMessage;

/**
* Checks if two states are equal. This is used to check against cached state
* to see if validity needs to be re-computed.
*
* @param prev The previous state.
* @param next The next state.
* @return True if the states are equal, or false if not.
*/
protected abstract equals(prev: State, next: State): boolean;

/**
* Creates a copy of a state. This is used to cache state and check if it
* changes.
*
* @param state The state to copy.
* @return A copy of the state.
*/
protected abstract copy(state: State): State;
}

/**
* An object containing `ValidityStateFlags` and a corresponding validation
* message.
*/
export interface ValidityAndMessage {
/**
* Validity flags.
*/
validity: ValidityStateFlags;

/**
* The validation message for the associated flags. It may not be an empty
* string if any of the validity flags are `true`.
*/
validationMessage: string;
}
Loading

0 comments on commit 8eb1f30

Please sign in to comment.