From 5606eefc38b08ebeb5f63d54b4b82631ea67936a Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Wed, 26 Jul 2023 14:10:58 -0700 Subject: [PATCH] feat(checkbox): add required and form validity PiperOrigin-RevId: 551311394 --- checkbox/internal/checkbox.ts | 143 ++++++++++++++++++++++++++++- checkbox/internal/checkbox_test.ts | 53 +++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/checkbox/internal/checkbox.ts b/checkbox/internal/checkbox.ts index 6a4dba84e5..ee7a25dba5 100644 --- a/checkbox/internal/checkbox.ts +++ b/checkbox/internal/checkbox.ts @@ -54,6 +54,14 @@ export class Checkbox extends LitElement { */ @property({type: Boolean}) indeterminate = false; + /** + * When true, require the checkbox to be selected when participating in + * form submission. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#validation + */ + @property({type: Boolean}) required = false; + /** * The value of the checkbox that is submitted with a form when selected. * @@ -85,10 +93,46 @@ export class Checkbox extends LitElement { return this.internals.labels; } + /** + * 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; + @query('.outline') private readonly outline!: HTMLElement|null; private readonly internals = (this as HTMLElement /* needed for closure */).attachInternals(); @@ -105,7 +149,72 @@ export class Checkbox extends LitElement { } } - protected override update(changed: PropertyValues) { + /** + * 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(); + } + + /** + * 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 checkbox's `error` state will be set to true if invalid, or false if + * valid. + * + * @return true if the checkbox is valid, or false if not. + */ + showValidity() { + const isValid = this.checkValidity(); + this.error = !isValid; + return isValid; + } + + /** + * 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.internals.setValidity({customError: !!error}, error); + } + + protected override update(changed: PropertyValues) { if (changed.has('checked') || changed.has('disabled') || changed.has('indeterminate')) { this.prevChecked = changed.get('checked') ?? this.checked; @@ -159,6 +268,7 @@ export class Checkbox extends LitElement { aria-label=${ariaLabel || nothing} aria-invalid=${this.error || nothing} ?disabled=${this.disabled} + ?required=${this.required} .indeterminate=${this.indeterminate} .checked=${this.checked} @change=${this.handleChange} @@ -175,6 +285,37 @@ export class Checkbox extends LitElement { redispatchEvent(this, event); } + 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.internals.validity.customError) { + input.setCustomValidity(this.internals.validationMessage); + } else { + input.setCustomValidity(''); + } + + this.internals.setValidity( + input.validity, input.validationMessage, this.outline!); + } + + 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!; + } + /** @private */ formResetCallback() { // The checked property does not reflect, so the original attribute set by diff --git a/checkbox/internal/checkbox_test.ts b/checkbox/internal/checkbox_test.ts index 43afdf5991..574c9e306e 100644 --- a/checkbox/internal/checkbox_test.ts +++ b/checkbox/internal/checkbox_test.ts @@ -215,4 +215,57 @@ describe('checkbox', () => { expect(element.checked).toBeFalse(); }); }); + + describe('validation', () => { + it('should set valueMissing when required and not selected', async () => { + const {harness} = await setupTest(); + harness.element.required = true; + + expect(harness.element.validity.valueMissing) + .withContext('checkbox.validity.valueMissing') + .toBeTrue(); + }); + + it('should not set valueMissing when required and checked', async () => { + const {harness} = await setupTest(); + harness.element.required = true; + harness.element.checked = true; + + expect(harness.element.validity.valueMissing) + .withContext('checkbox.validity.valueMissing') + .toBeFalse(); + }); + + it('should set valueMissing when required and indeterminate', async () => { + const {harness} = await setupTest(); + harness.element.required = true; + harness.element.indeterminate = true; + + expect(harness.element.validity.valueMissing) + .withContext('checkbox.validity.valueMissing') + .toBeTrue(); + }); + + it('should set error to true when showValidity() is called and checkbox is invalid', + async () => { + const {harness} = await setupTest(); + harness.element.required = true; + + harness.element.showValidity(); + expect(harness.element.error).withContext('checkbox.error').toBeTrue(); + }); + + it('should set error to false when showValidity() is called and checkbox is valid', + async () => { + const {harness} = await setupTest(); + harness.element.required = true; + harness.element.error = true; + harness.element.checked = true; + + harness.element.showValidity(); + expect(harness.element.error) + .withContext('checkbox.error') + .toBeFalse(); + }); + }); });