Skip to content

Commit

Permalink
feat(text-field): add native validation APIs
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 467226058
  • Loading branch information
asyncLiz authored and copybara-github committed Aug 12, 2022
1 parent 58848f6 commit e2e2c9d
Show file tree
Hide file tree
Showing 2 changed files with 233 additions and 0 deletions.
108 changes: 108 additions & 0 deletions textfield/lib/text-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
*
Expand All @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down
125 changes: 125 additions & 0 deletions textfield/lib/text-field_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
});

0 comments on commit e2e2c9d

Please sign in to comment.