refactor: add constraint validation mixin

This reduces the copy/paste of validation code. Constraint validation must be synchronous, so a `Validator` helps compute the validity and cache it since the validity must be checked when properties change.

Implemented in checkbox-like controls.

PiperOrigin-RevId: 584380464
This commit is contained in:
Elizabeth Mitchell 2023-11-21 11:21:18 -08:00 committed by Copybara-Service
parent 3d8c7ac7f3
commit f7a66a8bbe
4 changed files with 454 additions and 228 deletions

View File

@ -19,9 +19,11 @@ import {
redispatchEvent,
} from '../../internal/controller/events.js';
import {
internals,
mixinElementInternals,
} from '../../labs/behaviors/element-internals.js';
createValidator,
getValidityAnchor,
mixinConstraintValidation,
} from '../../labs/behaviors/constraint-validation.js';
import {mixinElementInternals} from '../../labs/behaviors/element-internals.js';
import {
getFormState,
getFormValue,
@ -30,8 +32,8 @@ import {
import {CheckboxValidator} from '../../labs/behaviors/validators/checkbox-validator.js';
// Separate variable needed for closure.
const checkboxBaseClass = mixinFormAssociated(
mixinElementInternals(LitElement),
const checkboxBaseClass = mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
);
/**
@ -83,111 +85,24 @@ export class Checkbox extends checkboxBaseClass {
*/
@property() value = 'on';
/**
* Returns a ValidityState object that represents the validity states of the
* checkbox.
*
* Note that checkboxes will only set `valueMissing` if `required` and not
* checked.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#validation
*/
get validity() {
this.syncValidity();
return this[internals].validity;
}
/**
* Returns the native validation error message.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process
*/
get validationMessage() {
this.syncValidity();
return this[internals].validationMessage;
}
/**
* Returns whether an element will successfully validate based on forms
* validation rules and constraints.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process
*/
get willValidate() {
this.syncValidity();
return this[internals].willValidate;
}
@state() private prevChecked = false;
@state() private prevDisabled = false;
@state() private prevIndeterminate = false;
@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 customValidityError = '';
constructor() {
super();
if (!isServer) {
this.addEventListener('click', (event: MouseEvent) => {
if (!isActivationClick(event)) {
if (!isActivationClick(event) || !this.input) {
return;
}
this.focus();
dispatchActivationClick(this.input!);
dispatchActivationClick(this.input);
});
}
}
/**
* Checks the checkbox's native validation and returns whether or not the
* element is valid.
*
* If invalid, this method will dispatch the `invalid` event.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/checkValidity
*
* @return true if the checkbox is valid, or false if not.
*/
checkValidity() {
this.syncValidity();
return this[internals].checkValidity();
}
/**
* Checks the checkbox's native validation and returns whether or not the
* element is valid.
*
* If invalid, this method will dispatch the `invalid` event.
*
* The `validationMessage` is reported to the user by the browser. Use
* `setCustomValidity()` to customize the `validationMessage`.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity
*
* @return true if the checkbox is valid, or false if not.
*/
reportValidity() {
this.syncValidity();
return this[internals].reportValidity();
}
/**
* Sets the checkbox's native validation error message. This is used to
* customize `validationMessage`.
*
* When the error is not an empty string, the checkbox is considered invalid
* and `validity.customError` will be true.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setCustomValidity
*
* @param error The error message to display.
*/
setCustomValidity(error: string) {
this.customValidityError = error;
this.syncValidity();
}
protected override update(changed: PropertyValues<Checkbox>) {
if (
changed.has('checked') ||
@ -252,12 +167,6 @@ export class Checkbox extends checkboxBaseClass {
`;
}
protected override updated() {
// Sync validity when properties change, since validation properties may
// have changed.
this.syncValidity();
}
private handleChange(event: Event) {
const target = event.target as HTMLInputElement;
this.checked = target.checked;
@ -266,18 +175,6 @@ export class Checkbox extends checkboxBaseClass {
redispatchEvent(this, event);
}
private syncValidity() {
const {validity, validationMessage} = this.validator.getValidity();
this[internals].setValidity(
{
...validity,
customError: !!this.customValidityError,
},
this.customValidityError || validationMessage,
this.input ?? undefined,
);
}
// Writable mixin properties for lit-html binding, needed for lit-analyzer
declare disabled: boolean;
declare name: string;
@ -304,5 +201,11 @@ export class Checkbox extends checkboxBaseClass {
this.checked = state === 'true';
}
private readonly validator = new CheckboxValidator(() => this);
[createValidator]() {
return new CheckboxValidator(() => this);
}
[getValidityAnchor]() {
return this.input;
}
}

View File

@ -0,0 +1,241 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {isServer, LitElement, PropertyDeclaration} from 'lit';
import {internals, WithElementInternals} from './element-internals.js';
import {FormAssociated} from './form-associated.js';
import {MixinBase, MixinReturn} from './mixin.js';
import {Validator} from './validators/validator.js';
/**
* A form associated element that provides constraint validation APIs.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation
*/
export interface ConstraintValidation {
/**
* Returns a ValidityState object that represents the validity states of the
* element.
*
* https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
*/
readonly validity: ValidityState;
/**
* Returns a validation error message or an empty string if the element is
* valid.
*
* https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/validationMessage
*/
readonly validationMessage: string;
/**
* Returns whether an element will successfully validate based on forms
* validation rules and constraints.
*
* Disabled and readonly elements will not validate.
*
* https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/willValidate
*/
readonly willValidate: boolean;
/**
* Checks the element's constraint validation and returns true if the element
* is valid or false if not.
*
* If invalid, this method will dispatch an `invalid` event.
*
* https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/checkValidity
*
* @return true if the element is valid, or false if not.
*/
checkValidity(): boolean;
/**
* Checks the element's constraint validation and returns true if the element
* is valid or false if not.
*
* If invalid, this method will dispatch a cancelable `invalid` event. If not
* canceled, a the current `validationMessage` will be reported to the user.
*
* https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/reportValidity
*
* @return true if the element is valid, or false if not.
*/
reportValidity(): boolean;
/**
* Sets the element's constraint validation error message. When set to a
* non-empty string, `validity.customError` will be true and
* `validationMessage` will display the provided error.
*
* Use this method to customize error messages reported.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setCustomValidity
*
* @param error The error message to display, or an empty string.
*/
setCustomValidity(error: string): void;
/**
* Creates and returns a `Validator` that is used to compute and cache
* validity for the element.
*
* A validator that caches validity is important since constraint validation
* must be computed synchronously and frequently in response to constraint
* validation property changes.
*/
[createValidator](): Validator<unknown>;
/**
* Returns shadow DOM child that is used as the anchor for the platform
* `reportValidity()` popup. This is often the root element or the inner
* focus-delegated element.
*/
[getValidityAnchor](): HTMLElement | null;
}
/**
* A symbol property used to create a constraint validation `Validator`.
* Required for all `mixinConstraintValidation()` elements.
*/
export const createValidator = Symbol('createValidator');
/**
* A symbol property used to return an anchor for constraint validation popups.
* Required for all `mixinConstraintValidation()` elements.
*/
export const getValidityAnchor = Symbol('getValidityAnchor');
// Private symbol members, used to avoid name clashing.
const privateValidator = Symbol('privateValidator');
const privateSyncValidity = Symbol('privateSyncValidity');
const privateCustomValidationMessage = Symbol('privateCustomValidationMessage');
/**
* Mixins in constraint validation APIs for an element.
*
* See https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation
* for more details.
*
* Implementations must provide a validator to cache and compute its validity,
* along with a shadow root element to anchor validation popups to.
*
* @example
* ```ts
* const baseClass = mixinConstraintValidation(
* mixinFormAssociated(mixinElementInternals(LitElement))
* );
*
* class MyCheckbox extends baseClass {
* \@property({type: Boolean}) checked = false;
* \@property({type: Boolean}) required = false;
*
* [createValidator]() {
* return new CheckboxValidator(() => this);
* }
*
* [getValidityAnchor]() {
* return this.renderRoot.querySelector('.root');
* }
* }
* ```
*
* @param base The class to mix functionality into.
* @return The provided class with `ConstraintValidation` mixed in.
*/
export function mixinConstraintValidation<
T extends MixinBase<LitElement & FormAssociated & WithElementInternals>,
>(base: T): MixinReturn<T, ConstraintValidation> {
abstract class ConstraintValidationElement
extends base
implements ConstraintValidation
{
get validity() {
this[privateSyncValidity]();
return this[internals].validity;
}
get validationMessage() {
this[privateSyncValidity]();
return this[internals].validationMessage;
}
get willValidate() {
this[privateSyncValidity]();
return this[internals].willValidate;
}
/**
* A validator instance created from `[createValidator]()`.
*/
[privateValidator]?: Validator<unknown>;
/**
* Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432
* Replace with this[internals].validity.customError when resolved.
*/
[privateCustomValidationMessage] = '';
checkValidity() {
this[privateSyncValidity]();
return this[internals].checkValidity();
}
reportValidity() {
this[privateSyncValidity]();
return this[internals].reportValidity();
}
setCustomValidity(error: string) {
this[privateCustomValidationMessage] = error;
this[privateSyncValidity]();
}
override requestUpdate(
name?: PropertyKey,
oldValue?: unknown,
options?: PropertyDeclaration,
) {
super.requestUpdate(name, oldValue, options);
this[privateSyncValidity]();
}
[privateSyncValidity]() {
if (isServer) {
return;
}
if (!this[privateValidator]) {
this[privateValidator] = this[createValidator]();
}
const {validity, validationMessage: nonCustomValidationMessage} =
this[privateValidator].getValidity();
const customError = !!this[privateCustomValidationMessage];
const validationMessage =
this[privateCustomValidationMessage] || nonCustomValidationMessage;
this[internals].setValidity(
{...validity, customError},
validationMessage,
this[getValidityAnchor]() ?? undefined,
);
}
[createValidator](): Validator<unknown> {
throw new Error('Implement [createValidator]');
}
[getValidityAnchor](): HTMLElement | null {
throw new Error('Implement [getValidityAnchor]');
}
}
return ConstraintValidationElement;
}

View File

@ -0,0 +1,177 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// import 'jasmine'; (google3-only)
import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {
createValidator,
getValidityAnchor,
mixinConstraintValidation,
} from './constraint-validation.js';
import {mixinElementInternals} from './element-internals.js';
import {getFormValue, mixinFormAssociated} from './form-associated.js';
import {CheckboxValidator} from './validators/checkbox-validator.js';
describe('mixinConstraintValidation()', () => {
const baseClass = mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
);
@customElement('test-constraint-validation')
class TestConstraintValidation extends baseClass {
@property({type: Boolean}) checked = false;
@property({type: Boolean}) required = false;
override render() {
return html`<div id="root"></div>`;
}
[createValidator]() {
return new CheckboxValidator(() => this);
}
[getValidityAnchor]() {
return this.shadowRoot?.querySelector<HTMLElement>('#root') ?? null;
}
[getFormValue]() {
return String(this.checked);
}
}
describe('validity', () => {
it('should return a ValidityState value', () => {
const control = new TestConstraintValidation();
expect(control.validity).toBeInstanceOf(ValidityState);
});
it('should update synchronously when validation properties change', () => {
const control = new TestConstraintValidation();
expect(control.validity.valid)
.withContext('validity.valid before changing required')
.toBeTrue();
control.required = true;
expect(control.validity.valid)
.withContext('validity.valid after changing required')
.toBeFalse();
});
});
describe('validationMessage', () => {
it('should be an empty string when valid', () => {
const control = new TestConstraintValidation();
expect(control.validationMessage).toBe('');
});
it('should have an error message when invalid', () => {
const control = new TestConstraintValidation();
control.required = true;
expect(control.validationMessage).not.toBe('');
});
});
describe('willValidate', () => {
it('should validate by default', () => {
const control = new TestConstraintValidation();
expect(control.willValidate).withContext('willValidate').toBeTrue();
});
it('should not validate when a disabled attribute is present', () => {
const control = new TestConstraintValidation();
control.toggleAttribute('disabled', true);
expect(control.willValidate).withContext('willValidate').toBeFalse();
});
it('should not validate when a readonly attribute is present', () => {
const control = new TestConstraintValidation();
control.toggleAttribute('readonly', true);
expect(control.willValidate).withContext('willValidate').toBeFalse();
});
});
describe('checkValidity()', () => {
it('should return true when element is valid', () => {
const control = new TestConstraintValidation();
expect(control.checkValidity())
.withContext('checkValidity() return')
.toBeTrue();
});
it('should return false when element is invalid', () => {
const control = new TestConstraintValidation();
control.required = true;
expect(control.checkValidity())
.withContext('checkValidity() return')
.toBeFalse();
});
it('should dispatch invalid event when invalid', () => {
const control = new TestConstraintValidation();
control.required = true;
const invalidListener = jasmine.createSpy('invalidListener');
control.addEventListener('invalid', invalidListener);
control.checkValidity();
expect(invalidListener).toHaveBeenCalledWith(jasmine.any(Event));
});
});
describe('reportValidity()', () => {
it('should return true when element is valid', () => {
const control = new TestConstraintValidation();
expect(control.reportValidity())
.withContext('reportValidity() return')
.toBeTrue();
});
it('should return false when element is invalid', () => {
const control = new TestConstraintValidation();
control.required = true;
expect(control.reportValidity())
.withContext('reportValidity() return')
.toBeFalse();
});
it('should dispatch invalid event when invalid', () => {
const control = new TestConstraintValidation();
control.required = true;
const invalidListener = jasmine.createSpy('invalidListener');
control.addEventListener('invalid', invalidListener);
control.reportValidity();
expect(invalidListener).toHaveBeenCalledWith(jasmine.any(Event));
});
});
describe('setCustomValidity()', () => {
it('should set customError to true when given a non-empty string', () => {
const control = new TestConstraintValidation();
control.setCustomValidity('Error');
expect(control.validity.customError)
.withContext('validity.customError')
.toBeTrue();
});
it('should set customError to false when set to an empty string', () => {
const control = new TestConstraintValidation();
control.setCustomValidity('');
expect(control.validity.customError)
.withContext('validity.customError')
.toBeFalse();
});
it('should report custom validation message over other validation messages', () => {
const control = new TestConstraintValidation();
control.required = true;
control.setCustomValidity('Error');
expect(control.validationMessage)
.withContext('validationMessage')
.toBe('Error');
});
});
});

View File

@ -18,9 +18,11 @@ import {
redispatchEvent,
} from '../../internal/controller/events.js';
import {
internals,
mixinElementInternals,
} from '../../labs/behaviors/element-internals.js';
createValidator,
getValidityAnchor,
mixinConstraintValidation,
} from '../../labs/behaviors/constraint-validation.js';
import {mixinElementInternals} from '../../labs/behaviors/element-internals.js';
import {
getFormState,
getFormValue,
@ -29,7 +31,9 @@ import {
import {CheckboxValidator} from '../../labs/behaviors/validators/checkbox-validator.js';
// Separate variable needed for closure.
const switchBaseClass = mixinFormAssociated(mixinElementInternals(LitElement));
const switchBaseClass = mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
);
/**
* @fires input {InputEvent} Fired whenever `selected` changes due to user
@ -80,108 +84,21 @@ export class Switch extends switchBaseClass {
*/
@property() value = 'on';
/**
* Returns a ValidityState object that represents the validity states of the
* switch.
*
* Note that switches will only set `valueMissing` if `required` and not
* selected.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#validation
*/
get validity() {
this.syncValidity();
return this[internals].validity;
}
/**
* Returns the native validation error message.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process
*/
get validationMessage() {
this.syncValidity();
return this[internals].validationMessage;
}
/**
* Returns whether an element will successfully validate based on forms
* validation rules and constraints.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process
*/
get willValidate() {
this.syncValidity();
return this[internals].willValidate;
}
@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 customValidityError = '';
constructor() {
super();
if (!isServer) {
this.addEventListener('click', (event: MouseEvent) => {
if (!isActivationClick(event)) {
if (!isActivationClick(event) || !this.input) {
return;
}
this.focus();
dispatchActivationClick(this.input!);
dispatchActivationClick(this.input);
});
}
}
/**
* Checks the switch's native validation and returns whether or not the
* element is valid.
*
* If invalid, this method will dispatch the `invalid` event.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/checkValidity
*
* @return true if the switch is valid, or false if not.
*/
checkValidity() {
this.syncValidity();
return this[internals].checkValidity();
}
/**
* Checks the switch's native validation and returns whether or not the
* element is valid.
*
* If invalid, this method will dispatch the `invalid` event.
*
* The `validationMessage` is reported to the user by the browser. Use
* `setCustomValidity()` to customize the `validationMessage`.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity
*
* @return true if the switch is valid, or false if not.
*/
reportValidity() {
this.syncValidity();
return this[internals].reportValidity();
}
/**
* Sets the switch's native validation error message. This is used to
* customize `validationMessage`.
*
* When the error is not an empty string, the switch is considered invalid
* and `validity.customError` will be true.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setCustomValidity
*
* @param error The error message to display.
*/
setCustomValidity(error: string) {
this.customValidityError = error;
this.syncValidity();
}
protected override render(): TemplateResult {
// NOTE: buttons must use only [phrasing
// content](https://html.spec.whatwg.org/multipage/dom.html#phrasing-content)
@ -205,12 +122,6 @@ export class Switch extends switchBaseClass {
`;
}
protected override updated() {
// Sync validity when properties change, since validation properties may
// have changed.
this.syncValidity();
}
private getRenderClasses(): ClassInfo {
return {
'selected': this.selected,
@ -281,18 +192,6 @@ export class Switch extends switchBaseClass {
redispatchEvent(this, event);
}
private syncValidity() {
const {validity, validationMessage} = this.validator.getValidity();
this[internals].setValidity(
{
...validity,
customError: !!this.customValidityError,
},
this.customValidityError || validationMessage,
this.input ?? undefined,
);
}
// Writable mixin properties for lit-html binding, needed for lit-analyzer
declare disabled: boolean;
declare name: string;
@ -315,8 +214,14 @@ export class Switch extends switchBaseClass {
this.selected = state === 'true';
}
private readonly validator = new CheckboxValidator(() => ({
checked: this.selected,
required: this.required,
}));
[createValidator]() {
return new CheckboxValidator(() => ({
checked: this.selected,
required: this.required,
}));
}
[getValidityAnchor]() {
return this.input;
}
}