refactor(text-field): add validator and use validity mixins

PiperOrigin-RevId: 587086864
This commit is contained in:
Elizabeth Mitchell 2023-12-01 12:31:52 -08:00 committed by Copybara-Service
parent 77fd17787f
commit 52e568ddc8
4 changed files with 652 additions and 191 deletions

View File

@ -0,0 +1,271 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Validator} from './validator.js';
/**
* Constraint validation for a text field.
*/
export interface TextFieldState {
/**
* The input or textarea state to validate.
*/
state: InputState | TextAreaState;
/**
* The `<input>` or `<textarea>` that is rendered on the page.
*
* `minlength` and `maxlength` validation do not apply until a user has
* interacted with the control and the element is internally marked as dirty.
* This is a spec quirk, the two properties behave differently from other
* constraint validation.
*
* This means we need an actual rendered element instead of a virtual one,
* since the virtual element will never be marked as dirty.
*
* This can be `null` if the element has not yet rendered, and the validator
* will fall back to virtual elements for other constraint validation
* properties, which do apply even if the control is not dirty.
*/
renderedControl: HTMLInputElement | HTMLTextAreaElement | null;
}
/**
* Constraint validation properties for an `<input>`.
*/
export interface InputState extends SharedInputAndTextAreaState {
/**
* The `<input>` type.
*
* Not all constraint validation properties apply to every type. See
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#validation-related_attributes
* for which properties will apply to which types.
*/
readonly type: string;
/**
* The regex pattern a value must match.
*/
readonly pattern: string;
/**
* The minimum value.
*/
readonly min: string;
/**
* The maximum value.
*/
readonly max: string;
/**
* The step interval of the value.
*/
readonly step: string;
}
/**
* Constraint validation properties for a `<textarea>`.
*/
export interface TextAreaState extends SharedInputAndTextAreaState {
/**
* The type, must be "textarea" to inform the validator to use `<textarea>`
* instead of `<input>`.
*/
readonly type: 'textarea';
}
/**
* Constraint validation properties shared between an `<input>` and
* `<textarea>`.
*/
interface SharedInputAndTextAreaState {
/**
* The current value.
*/
readonly value: string;
/**
* Whether the textarea is required.
*/
readonly required: boolean;
/**
* The minimum length of the value.
*/
readonly minLength: number;
/**
* The maximum length of the value.
*/
readonly maxLength: number;
}
/**
* A validator that provides constraint validation that emulates `<input>` and
* `<textarea>` validation.
*/
export class TextFieldValidator extends Validator<TextFieldState> {
private inputControl?: HTMLInputElement;
private textAreaControl?: HTMLTextAreaElement;
protected override computeValidity({state, renderedControl}: TextFieldState) {
let inputOrTextArea = renderedControl;
if (isInputState(state) && !inputOrTextArea) {
// Get cached <input> or create it.
inputOrTextArea = this.inputControl || document.createElement('input');
// Cache the <input> to re-use it next time.
this.inputControl = inputOrTextArea;
} else if (!inputOrTextArea) {
// Get cached <textarea> or create it.
inputOrTextArea =
this.textAreaControl || document.createElement('textarea');
// Cache the <textarea> to re-use it next time.
this.textAreaControl = inputOrTextArea;
}
// Set this variable so we can check it for input-specific properties.
const input = isInputState(state)
? (inputOrTextArea as HTMLInputElement)
: null;
// Set input's "type" first, since this can change the other properties
if (input) {
input.type = state.type;
}
if (inputOrTextArea.value !== state.value) {
// Only programmatically set the value if there's a difference. When using
// the rendered control, the value will always be up to date. Setting the
// property (even if it's the same string) will reset the internal <input>
// dirty flag, making minlength and maxlength validation reset.
inputOrTextArea.value = state.value;
}
inputOrTextArea.required = state.required;
// The following IDLAttribute properties will always hydrate an attribute,
// even if set to a the default value ('' or -1). The presence of the
// attribute triggers constraint validation, so we must remove the attribute
// when empty.
if (input) {
const inputState = state as InputState;
if (inputState.pattern) {
input.pattern = inputState.pattern;
} else {
input.removeAttribute('pattern');
}
if (inputState.min) {
input.min = inputState.min;
} else {
input.removeAttribute('min');
}
if (inputState.max) {
input.max = inputState.max;
} else {
input.removeAttribute('max');
}
if (inputState.step) {
input.step = inputState.step;
} else {
input.removeAttribute('step');
}
}
// Use -1 to represent no minlength and maxlength, which is what the
// platform input returns. However, it will throw an error if you try to
// manually set it to -1.
if (state.minLength > -1) {
inputOrTextArea.minLength = state.minLength;
} else {
inputOrTextArea.removeAttribute('minlength');
}
if (state.maxLength > -1) {
inputOrTextArea.maxLength = state.maxLength;
} else {
inputOrTextArea.removeAttribute('maxlength');
}
return {
validity: inputOrTextArea.validity,
validationMessage: inputOrTextArea.validationMessage,
};
}
protected override equals(
{state: prev}: TextFieldState,
{state: next}: TextFieldState,
) {
// Check shared input and textarea properties
const inputOrTextAreaEqual =
prev.type === next.type &&
prev.value === next.value &&
prev.required === next.required &&
prev.minLength === next.minLength &&
prev.maxLength === next.maxLength;
if (!isInputState(prev) || !isInputState(next)) {
// Both are textareas, all relevant properties are equal.
return inputOrTextAreaEqual;
}
// Check additional input-specific properties.
return (
inputOrTextAreaEqual &&
prev.pattern === next.pattern &&
prev.min === next.min &&
prev.max === next.max &&
prev.step === next.step
);
}
protected override copy({state}: TextFieldState): TextFieldState {
// Don't hold a reference to the rendered control when copying since we
// don't use it when checking if the state changed.
return {
state: isInputState(state)
? this.copyInput(state)
: this.copyTextArea(state),
renderedControl: null,
};
}
private copyInput(state: InputState): InputState {
const {type, pattern, min, max, step} = state;
return {
...this.copySharedState(state),
type,
pattern,
min,
max,
step,
};
}
private copyTextArea(state: TextAreaState): TextAreaState {
return {
...this.copySharedState(state),
type: state.type,
};
}
private copySharedState({
value,
required,
minLength,
maxLength,
}: SharedInputAndTextAreaState): SharedInputAndTextAreaState {
return {value, required, minLength, maxLength};
}
}
function isInputState(state: InputState | TextAreaState): state is InputState {
return state.type !== 'textarea';
}

View File

@ -0,0 +1,318 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// import 'jasmine'; (google3-only)
import {
InputState,
TextAreaState,
TextFieldValidator,
} from './text-field-validator.js';
// Note: minlength and maxlength validation can NOT be tested programmatically.
// These properties will not trigger constraint validation until a user has
// interacted with the <input> or <textarea> and it marks itself as dirty.
// It's a spec quirk that these two properties behave differently, and
// unfortunately we cannot test them.
describe('TextFieldValidator', () => {
// These types all have the same "text"-like validation
describe('type="text", "password", "search", "tel", "url"', () => {
it('is invalid when required and empty', () => {
const state: InputState = {
type: 'text',
value: '',
required: true,
pattern: '',
min: '',
max: '',
minLength: -1,
maxLength: -1,
step: '',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeTrue();
expect(validationMessage).withContext('validationMessage').not.toBe('');
});
it('is valid when required and not empty', () => {
const state: InputState = {
type: 'text',
value: 'Value',
required: true,
pattern: '',
min: '',
max: '',
minLength: -1,
maxLength: -1,
step: '',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});
it('is valid when not required and empty', () => {
const state: InputState = {
type: 'text',
value: '',
required: false,
pattern: '',
min: '',
max: '',
minLength: -1,
maxLength: -1,
step: '',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});
});
describe('type="email"', () => {
it('is invalid when not matching default email pattern', () => {
const state: InputState = {
type: 'email',
value: 'invalid',
required: false,
pattern: '',
min: '',
max: '',
minLength: -1,
maxLength: -1,
step: '',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.typeMismatch).withContext('typeMismatch').toBeTrue();
expect(validationMessage).withContext('validationMessage').not.toBe('');
});
it('is valid when matching default email pattern', () => {
const state: InputState = {
type: 'email',
value: 'valid@google.com',
required: false,
pattern: '',
min: '',
max: '',
minLength: -1,
maxLength: -1,
step: '',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.typeMismatch).withContext('typeMismatch').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});
});
describe('type="number"', () => {
it('is invalid when value is less than min', () => {
const state: InputState = {
type: 'number',
value: '1',
required: false,
pattern: '',
min: '5',
max: '',
minLength: -1,
maxLength: -1,
step: '',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.rangeUnderflow).withContext('rangeUnderflow').toBeTrue();
expect(validationMessage).withContext('validationMessage').not.toBe('');
});
it('is invalid when value is greater than max', () => {
const state: InputState = {
type: 'number',
value: '10',
required: false,
pattern: '',
min: '',
max: '5',
minLength: -1,
maxLength: -1,
step: '',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.rangeOverflow).withContext('rangeOverflow').toBeTrue();
expect(validationMessage).withContext('validationMessage').not.toBe('');
});
it('is valid when value is between min and max', () => {
const state: InputState = {
type: 'number',
value: '3',
required: false,
pattern: '',
min: '1',
max: '5',
minLength: -1,
maxLength: -1,
step: '',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.rangeUnderflow).withContext('rangeUnderflow').toBeFalse();
expect(validity.rangeOverflow).withContext('rangeOverflow').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});
it('is invalid when value does not match step', () => {
const state: InputState = {
type: 'number',
value: '2',
required: false,
pattern: '',
min: '',
max: '',
minLength: -1,
maxLength: -1,
step: '5',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.stepMismatch).withContext('stepMismatch').toBeTrue();
expect(validationMessage).withContext('validationMessage').not.toBe('');
});
it('is valid when value matches step', () => {
const state: InputState = {
type: 'number',
value: '20',
required: false,
pattern: '',
min: '',
max: '',
minLength: -1,
maxLength: -1,
step: '5',
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.stepMismatch).withContext('stepMismatch').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});
});
describe('type="textarea"', () => {
it('is invalid when required and empty', () => {
const state: TextAreaState = {
type: 'textarea',
value: '',
required: true,
minLength: -1,
maxLength: -1,
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeTrue();
expect(validationMessage).withContext('validationMessage').not.toBe('');
});
it('is valid when required and not empty', () => {
const state: TextAreaState = {
type: 'textarea',
value: 'Value',
required: true,
minLength: -1,
maxLength: -1,
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});
it('is valid when not required and empty', () => {
const state: TextAreaState = {
type: 'textarea',
value: '',
required: false,
minLength: -1,
maxLength: -1,
};
const validator = new TextFieldValidator(() => ({
state,
renderedControl: null,
}));
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});
});
});

View File

@ -210,6 +210,7 @@ const forms: MaterialStoryInit<StoryKnobs> = {
?disabled=${knobs.disabled}
label="First name"
name="first-name"
required
autocomplete="given-name"></md-filled-text-field>
<md-filled-text-field
?disabled=${knobs.disabled}
@ -226,8 +227,14 @@ const forms: MaterialStoryInit<StoryKnobs> = {
},
};
function reportValidity(event: Event) {
(event.target as MdFilledTextField).reportValidity();
async function reportValidity(event: Event) {
const textField = event.target as MdFilledTextField;
// Calling reportValidity() will focus the text field. Since we do this on
// "change" (blur), wait for other focus changes to finish, like tabbing.
await new Promise<void>((resolve) => {
setTimeout(resolve);
});
textField.reportValidity();
}
function clearInput(event: Event) {

View File

@ -4,12 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {html, LitElement, nothing, PropertyValues} from 'lit';
import {LitElement, PropertyValues, html, nothing} from 'lit';
import {property, query, queryAssignedElements, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {live} from 'lit/directives/live.js';
import {styleMap} from 'lit/directives/style-map.js';
import {html as staticHtml, StaticValue} from 'lit/static-html.js';
import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
import {StaticValue, html as staticHtml} from 'lit/static-html.js';
import {Field} from '../../field/internal/field.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
@ -17,13 +17,21 @@ import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {redispatchEvent} from '../../internal/controller/events.js';
import {stringConverter} from '../../internal/controller/string-converter.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 {
getFormValue,
mixinFormAssociated,
} from '../../labs/behaviors/form-associated.js';
import {
mixinOnReportValidity,
onReportValidity,
} from '../../labs/behaviors/on-report-validity.js';
import {TextFieldValidator} from '../../labs/behaviors/validators/text-field-validator.js';
import {Validator} from '../../labs/behaviors/validators/validator.js';
/**
* Input types that are compatible with the text field.
@ -64,8 +72,10 @@ export type InvalidTextFieldType =
| 'submit';
// Separate variable needed for closure.
const textFieldBaseClass = mixinFormAssociated(
mixinElementInternals(LitElement),
const textFieldBaseClass = mixinOnReportValidity(
mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
),
);
/**
@ -291,27 +301,6 @@ export abstract class TextField extends textFieldBaseClass {
*/
@property({reflect: true}) autocomplete = '';
/**
* Returns the text field's validation error message.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation
*/
get validationMessage() {
this.syncValidity();
return this[internals].validationMessage;
}
/**
* Returns a `ValidityState` object that represents the validity states of the
* text field.
*
* https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
*/
get validity() {
this.syncValidity();
return this[internals].validity;
}
/**
* The text field's value as a number.
*/
@ -354,17 +343,6 @@ export abstract class TextField extends textFieldBaseClass {
this.value = input.value;
}
/**
* Returns whether an element will successfully validate based on forms
* validation rules and constraints.
*
* https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/willValidate
*/
get willValidate() {
this.syncValidity();
return this[internals].willValidate;
}
protected abstract readonly fieldTag: StaticValue;
/**
@ -388,91 +366,15 @@ export abstract class TextField extends textFieldBaseClass {
}
@query('.input')
private readonly inputOrTextarea?:
private readonly inputOrTextarea!:
| HTMLInputElement
| HTMLTextAreaElement
| null;
@query('.field') private readonly field?: Field | null;
@query('.field') private readonly field!: Field | null;
@queryAssignedElements({slot: 'leading-icon'})
private readonly leadingIcons!: Element[];
@queryAssignedElements({slot: 'trailing-icon'})
private readonly trailingIcons!: Element[];
private isCheckingValidity = false;
private isReportingValidity = false;
// Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432
// Replace with this[internals].validity.customError when resolved.
private hasCustomValidityError = false;
/**
* 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() {
this.isCheckingValidity = true;
this.syncValidity();
const isValid = this[internals].checkValidity();
this.isCheckingValidity = false;
return isValid;
}
/**
* 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 display or clear an error text message equal to the text
* field's `validationMessage`, unless the invalid event is canceled.
*
* Use `setCustomValidity()` to customize the `validationMessage`.
*
* This method can also be used to re-announce error messages to screen
* readers.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity
*
* @return true if the text field is valid, or false if not.
*/
reportValidity() {
this.isReportingValidity = true;
let invalidEvent: Event | undefined;
this.addEventListener(
'invalid',
(event) => {
invalidEvent = event;
},
{once: true},
);
const valid = this.checkValidity();
this.showErrorMessage(valid, invalidEvent);
this.isReportingValidity = false;
return valid;
}
private showErrorMessage(valid: boolean, invalidEvent: Event | undefined) {
if (invalidEvent?.defaultPrevented) {
return valid;
}
const prevMessage = this.getErrorText();
this.nativeError = !valid;
this.nativeErrorText = this.validationMessage;
if (prevMessage === this.getErrorText()) {
this.field?.reannounceError();
}
return valid;
}
/**
* Selects all the text in the text field.
@ -483,26 +385,6 @@ export abstract class TextField extends textFieldBaseClass {
this.getInputOrTextarea().select();
}
/**
* Sets a custom validation error message for the text field. Use this for
* custom error message.
*
* 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.hasCustomValidityError = !!error;
this[internals].setValidity(
{customError: !!error},
error,
this.getInputOrTextarea(),
);
}
/**
* Replaces a range of text with a new string.
*
@ -627,10 +509,6 @@ export abstract class TextField extends textFieldBaseClass {
// before checking its value.
this.value = value;
}
// Sync validity when properties change, since validation properties may
// have changed.
this.syncValidity();
}
private renderField() {
@ -674,7 +552,7 @@ export abstract class TextField extends textFieldBaseClass {
}
private renderInputOrTextarea() {
const style = {direction: this.textDirection};
const style: StyleInfo = {'direction': this.textDirection};
const ariaLabel =
(this as ARIAMixinStrict).ariaLabel || this.label || nothing;
// lit-anaylzer `autocomplete` types are too strict
@ -699,7 +577,7 @@ export abstract class TextField extends textFieldBaseClass {
rows=${this.rows}
cols=${this.cols}
.value=${live(this.value)}
@change=${this.handleChange}
@change=${this.redispatchEvent}
@focusin=${this.handleFocusin}
@focusout=${this.handleFocusout}
@input=${this.handleInput}
@ -784,14 +662,6 @@ export abstract class TextField extends textFieldBaseClass {
private handleInput(event: InputEvent) {
this.dirty = true;
this.value = (event.target as HTMLInputElement).value;
// Sync validity so that clients can check validity on input.
this.syncValidity();
}
private handleChange(event: Event) {
// Sync validity so that clients can check validity on change.
this.syncValidity();
this.redispatchEvent(event);
}
private redispatchEvent(event: Event) {
@ -827,48 +697,11 @@ export abstract class TextField extends textFieldBaseClass {
return this.getInputOrTextarea() as HTMLInputElement;
}
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.getInputOrTextarea();
if (this.hasCustomValidityError) {
input.setCustomValidity(this[internals].validationMessage);
} else {
input.setCustomValidity('');
}
this[internals].setValidity(
input.validity,
input.validationMessage,
this.getInputOrTextarea(),
);
}
private handleIconChange() {
this.hasLeadingIcon = this.leadingIcons.length > 0;
this.hasTrailingIcon = this.trailingIcons.length > 0;
}
private readonly onInvalid = (invalidEvent: Event) => {
if (this.isCheckingValidity || this.isReportingValidity) {
return;
}
this.showErrorMessage(false, invalidEvent);
};
override connectedCallback() {
super.connectedCallback();
// Handles the case where the user submits the form and native validation
// error pops up. We want the error styles to show.
this.addEventListener('invalid', this.onInvalid);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('invalid', this.onInvalid);
}
// Writable mixin properties for lit-html binding, needed for lit-analyzer
declare disabled: boolean;
declare name: string;
@ -890,4 +723,36 @@ export abstract class TextField extends textFieldBaseClass {
// leading icon slot such as an iconbutton due to how delegatesFocus works.
this.getInputOrTextarea().focus();
}
[createValidator](): Validator<unknown> {
return new TextFieldValidator(() => ({
state: this,
renderedControl: this.inputOrTextarea,
}));
}
[getValidityAnchor](): HTMLElement | null {
return this.inputOrTextarea;
}
[onReportValidity](invalidEvent: Event | null) {
if (invalidEvent?.defaultPrevented) {
return;
}
if (invalidEvent) {
// Prevent default pop-up behavior. This also prevents focusing, so we
// manually focus.
invalidEvent.preventDefault();
this.focus();
}
const prevMessage = this.getErrorText();
this.nativeError = !!invalidEvent;
this.nativeErrorText = this.validationMessage;
if (prevMessage === this.getErrorText()) {
this.field?.reannounceError();
}
}
}