From e2e2c9d8a58b2487ce33fe05da4cf0ae35ac7d69 Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Fri, 12 Aug 2022 09:26:50 -0700 Subject: [PATCH] feat(text-field): add native validation APIs PiperOrigin-RevId: 467226058 --- textfield/lib/text-field.ts | 108 ++++++++++++++++++++++++++ textfield/lib/text-field_test.ts | 125 +++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) diff --git a/textfield/lib/text-field.ts b/textfield/lib/text-field.ts index fc91673f59..8e8fa2bfa9 100644 --- a/textfield/lib/text-field.ts +++ b/textfield/lib/text-field.ts @@ -43,12 +43,17 @@ export class TextField extends LitElement { @property({type: Boolean, reflect: true}) disabled = false; /** * Gets or sets whether or not the text field is in a visually invalid state. + * + * Calling `reportValidity()` will automatically update `error`. */ @property({type: Boolean, reflect: true}) error = false; /** * The error message that replaces supporting text when `error` is true. If * `errorText` is an empty string, then the supporting text will continue to * show. + * + * Calling `reportValidity()` will automatically update `errorText` to the + * native `validationMessage`. */ @property({type: String}) errorText = ''; @property({type: String}) label?: string; @@ -154,6 +159,26 @@ export class TextField extends LitElement { type: 'email'|'number'|'password'|'search'|'tel'|'text'|'url'|'color'|'date'| 'datetime-local'|'file'|'month'|'time'|'week' = 'text'; + /** + * Returns the native validation error message that would be displayed upon + * calling `reportValidity()`. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/validationMessage + */ + get validationMessage() { + return this.getInput().validationMessage; + } + + /** + * Returns a ValidityState object that represents the validity states of the + * text field. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/validity + */ + get validity() { + return this.getInput().validity; + } + /** * The text field's value as a number. */ @@ -176,6 +201,16 @@ export class TextField extends LitElement { this.value = this.getInput().value; } + /** + * Returns whether an element will successfully validate based on forms + * validation rules and constraints. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/willValidate + */ + get willValidate() { + return this.getInput().willValidate; + } + /** * Returns true when the text field has been interacted with. Native * validation errors only display in response to user interactions. @@ -197,6 +232,21 @@ export class TextField extends LitElement { this.addEventListener('click', this.focus); } + /** + * Checks the text field'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 text field is valid, or false if not. + */ + checkValidity() { + const {valid} = this.checkValidityAndDispatch(); + return valid; + } + override focus() { if (this.disabled || this.matches(':focus-within')) { // Don't shift focus from an element within the text field, like an icon @@ -209,6 +259,32 @@ export class TextField extends LitElement { this.getInput().focus(); } + /** + * Checks the text field's native validation and returns whether or not the + * element is valid. + * + * If invalid, this method will dispatch the `invalid` event. + * + * This method will update `error` to the current validity state and + * `errorText` to the current `validationMessage`, unless the invalid event is + * canceled. + * + * Use `setCustomValidity()` to customize the `validationMessage`. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity + * + * @return true if the text field is valid, or false if not. + */ + reportValidity() { + const {valid, canceled} = this.checkValidityAndDispatch(); + if (!canceled) { + this.error = !valid; + this.errorText = this.validationMessage; + } + + return valid; + } + /** * Selects all the text in the text field. * @@ -218,6 +294,21 @@ export class TextField extends LitElement { this.getInput().select(); } + /** + * Sets the text field's native validation error message. This is used to + * customize `validationMessage`. + * + * When the error is not an empty string, the text field 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.getInput().setCustomValidity(error); + } + /** * Replaces a range of text with a new string. * @@ -423,9 +514,26 @@ export class TextField extends LitElement { this.scheduleUpdate(); } + 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 checkValidityAndDispatch() { + const valid = this.getInput().checkValidity(); + let canceled = false; + if (!valid) { + canceled = !this.dispatchEvent(new Event('invalid', {cancelable: true})); + } + + return {valid, canceled}; + } + private handleIconChange() { this.hasLeadingIcon = this.leadingIcons.length > 0; this.hasTrailingIcon = this.trailingIcons.length > 0; diff --git a/textfield/lib/text-field_test.ts b/textfield/lib/text-field_test.ts index 981c4ccf4f..3ffdb9847f 100644 --- a/textfield/lib/text-field_test.ts +++ b/textfield/lib/text-field_test.ts @@ -232,5 +232,130 @@ describe('TextField', () => { }); }); + describe('native validation', () => { + it('should expose input validity', async () => { + const {testElement, input} = await setupTest(); + const spy = spyOnProperty(input, 'validity', 'get').and.callThrough(); + + expect(testElement.validity).toEqual(jasmine.any(Object)); + expect(spy).toHaveBeenCalled(); + }); + + it('should expose input validationMessage', async () => { + const {testElement, input} = await setupTest(); + const spy = + spyOnProperty(input, 'validationMessage', 'get').and.callThrough(); + + expect(testElement.validationMessage).toEqual(jasmine.any(String)); + expect(spy).toHaveBeenCalled(); + }); + + it('should expose input willValidate', async () => { + const {testElement, input} = await setupTest(); + const spy = spyOnProperty(input, 'willValidate', 'get').and.callThrough(); + + expect(testElement.willValidate).toEqual(jasmine.any(Boolean)); + expect(spy).toHaveBeenCalled(); + }); + + describe('checkValidity()', () => { + it('should return true if the text field is valid', async () => { + const {testElement} = await setupTest(); + + expect(testElement.checkValidity()).toBeTrue(); + }); + + it('should return false if the text field is invalid', async () => { + const {testElement} = await setupTest(); + testElement.required = true; + + expect(testElement.checkValidity()).toBeFalse(); + }); + + it('should not dispatch an invalid event when valid', async () => { + const {testElement} = await setupTest(); + const invalidHandler = jasmine.createSpy('invalidHandler'); + testElement.addEventListener('invalid', invalidHandler); + + testElement.checkValidity(); + + expect(invalidHandler).not.toHaveBeenCalled(); + }); + + it('should dispatch an invalid event when invalid', async () => { + const {testElement} = await setupTest(); + const invalidHandler = jasmine.createSpy('invalidHandler'); + testElement.addEventListener('invalid', invalidHandler); + testElement.required = true; + + testElement.checkValidity(); + + expect(invalidHandler).toHaveBeenCalled(); + }); + }); + + describe('reportValidity()', () => { + it('should return true when valid and set error to false', async () => { + const {testElement} = await setupTest(); + testElement.error = true; + + const valid = testElement.reportValidity(); + + expect(valid).withContext('valid').toBeTrue(); + expect(testElement.error).withContext('testElement.error').toBeFalse(); + }); + + it('should return false when invalid and set error to true', async () => { + const {testElement} = await setupTest(); + testElement.required = true; + + const valid = testElement.reportValidity(); + + expect(valid).withContext('valid').toBeFalse(); + expect(testElement.error).withContext('testElement.error').toBeTrue(); + }); + + it('should update errorText to validationMessage', async () => { + const {testElement} = await setupTest(); + const errorMessage = 'Error message'; + testElement.setCustomValidity(errorMessage); + + testElement.reportValidity(); + + expect(testElement.errorText).toEqual(errorMessage); + }); + + it('should not update error or errorText if invalid event is canceled', + async () => { + const {testElement} = await setupTest(); + testElement.addEventListener('invalid', e => { + e.preventDefault(); + }); + const errorMessage = 'Error message'; + testElement.setCustomValidity(errorMessage); + + const valid = testElement.reportValidity(); + + expect(valid).withContext('valid').toBeFalse(); + expect(testElement.error) + .withContext('testElement.error') + .toBeFalse(); + expect(testElement.errorText).toEqual(''); + }); + }); + + describe('setCustomValidity()', () => { + it('should call input.setCustomValidity()', async () => { + const {testElement, input} = await setupTest(); + spyOn(input, 'setCustomValidity').and.callThrough(); + + const errorMessage = 'Error message'; + testElement.setCustomValidity(errorMessage); + + expect(input.setCustomValidity).toHaveBeenCalledWith(errorMessage); + }); + }); + }); + // TODO(b/235238545): Add shared FormController tests. });