mirror of
https://github.com/material-components/material-web.git
synced 2026-01-09 07:21:09 +08:00
refactor(text-field): add validator and use validity mixins
PiperOrigin-RevId: 587086864
This commit is contained in:
parent
77fd17787f
commit
52e568ddc8
271
labs/behaviors/validators/text-field-validator.ts
Normal file
271
labs/behaviors/validators/text-field-validator.ts
Normal 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';
|
||||
}
|
||||
318
labs/behaviors/validators/text-field-validator_test.ts
Normal file
318
labs/behaviors/validators/text-field-validator_test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user