Skip to content

Commit 52e568d

Browse files
asyncLizcopybara-github
authored andcommitted
refactor(text-field): add validator and use validity mixins
PiperOrigin-RevId: 587086864
1 parent 77fd177 commit 52e568d

File tree

4 files changed

+652
-191
lines changed

4 files changed

+652
-191
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {Validator} from './validator.js';
8+
9+
/**
10+
* Constraint validation for a text field.
11+
*/
12+
export interface TextFieldState {
13+
/**
14+
* The input or textarea state to validate.
15+
*/
16+
state: InputState | TextAreaState;
17+
18+
/**
19+
* The `<input>` or `<textarea>` that is rendered on the page.
20+
*
21+
* `minlength` and `maxlength` validation do not apply until a user has
22+
* interacted with the control and the element is internally marked as dirty.
23+
* This is a spec quirk, the two properties behave differently from other
24+
* constraint validation.
25+
*
26+
* This means we need an actual rendered element instead of a virtual one,
27+
* since the virtual element will never be marked as dirty.
28+
*
29+
* This can be `null` if the element has not yet rendered, and the validator
30+
* will fall back to virtual elements for other constraint validation
31+
* properties, which do apply even if the control is not dirty.
32+
*/
33+
renderedControl: HTMLInputElement | HTMLTextAreaElement | null;
34+
}
35+
36+
/**
37+
* Constraint validation properties for an `<input>`.
38+
*/
39+
export interface InputState extends SharedInputAndTextAreaState {
40+
/**
41+
* The `<input>` type.
42+
*
43+
* Not all constraint validation properties apply to every type. See
44+
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#validation-related_attributes
45+
* for which properties will apply to which types.
46+
*/
47+
readonly type: string;
48+
49+
/**
50+
* The regex pattern a value must match.
51+
*/
52+
readonly pattern: string;
53+
54+
/**
55+
* The minimum value.
56+
*/
57+
readonly min: string;
58+
59+
/**
60+
* The maximum value.
61+
*/
62+
readonly max: string;
63+
64+
/**
65+
* The step interval of the value.
66+
*/
67+
readonly step: string;
68+
}
69+
70+
/**
71+
* Constraint validation properties for a `<textarea>`.
72+
*/
73+
export interface TextAreaState extends SharedInputAndTextAreaState {
74+
/**
75+
* The type, must be "textarea" to inform the validator to use `<textarea>`
76+
* instead of `<input>`.
77+
*/
78+
readonly type: 'textarea';
79+
}
80+
81+
/**
82+
* Constraint validation properties shared between an `<input>` and
83+
* `<textarea>`.
84+
*/
85+
interface SharedInputAndTextAreaState {
86+
/**
87+
* The current value.
88+
*/
89+
readonly value: string;
90+
91+
/**
92+
* Whether the textarea is required.
93+
*/
94+
readonly required: boolean;
95+
96+
/**
97+
* The minimum length of the value.
98+
*/
99+
readonly minLength: number;
100+
101+
/**
102+
* The maximum length of the value.
103+
*/
104+
readonly maxLength: number;
105+
}
106+
107+
/**
108+
* A validator that provides constraint validation that emulates `<input>` and
109+
* `<textarea>` validation.
110+
*/
111+
export class TextFieldValidator extends Validator<TextFieldState> {
112+
private inputControl?: HTMLInputElement;
113+
private textAreaControl?: HTMLTextAreaElement;
114+
115+
protected override computeValidity({state, renderedControl}: TextFieldState) {
116+
let inputOrTextArea = renderedControl;
117+
if (isInputState(state) && !inputOrTextArea) {
118+
// Get cached <input> or create it.
119+
inputOrTextArea = this.inputControl || document.createElement('input');
120+
// Cache the <input> to re-use it next time.
121+
this.inputControl = inputOrTextArea;
122+
} else if (!inputOrTextArea) {
123+
// Get cached <textarea> or create it.
124+
inputOrTextArea =
125+
this.textAreaControl || document.createElement('textarea');
126+
// Cache the <textarea> to re-use it next time.
127+
this.textAreaControl = inputOrTextArea;
128+
}
129+
130+
// Set this variable so we can check it for input-specific properties.
131+
const input = isInputState(state)
132+
? (inputOrTextArea as HTMLInputElement)
133+
: null;
134+
135+
// Set input's "type" first, since this can change the other properties
136+
if (input) {
137+
input.type = state.type;
138+
}
139+
140+
if (inputOrTextArea.value !== state.value) {
141+
// Only programmatically set the value if there's a difference. When using
142+
// the rendered control, the value will always be up to date. Setting the
143+
// property (even if it's the same string) will reset the internal <input>
144+
// dirty flag, making minlength and maxlength validation reset.
145+
inputOrTextArea.value = state.value;
146+
}
147+
148+
inputOrTextArea.required = state.required;
149+
150+
// The following IDLAttribute properties will always hydrate an attribute,
151+
// even if set to a the default value ('' or -1). The presence of the
152+
// attribute triggers constraint validation, so we must remove the attribute
153+
// when empty.
154+
if (input) {
155+
const inputState = state as InputState;
156+
if (inputState.pattern) {
157+
input.pattern = inputState.pattern;
158+
} else {
159+
input.removeAttribute('pattern');
160+
}
161+
162+
if (inputState.min) {
163+
input.min = inputState.min;
164+
} else {
165+
input.removeAttribute('min');
166+
}
167+
168+
if (inputState.max) {
169+
input.max = inputState.max;
170+
} else {
171+
input.removeAttribute('max');
172+
}
173+
174+
if (inputState.step) {
175+
input.step = inputState.step;
176+
} else {
177+
input.removeAttribute('step');
178+
}
179+
}
180+
181+
// Use -1 to represent no minlength and maxlength, which is what the
182+
// platform input returns. However, it will throw an error if you try to
183+
// manually set it to -1.
184+
if (state.minLength > -1) {
185+
inputOrTextArea.minLength = state.minLength;
186+
} else {
187+
inputOrTextArea.removeAttribute('minlength');
188+
}
189+
190+
if (state.maxLength > -1) {
191+
inputOrTextArea.maxLength = state.maxLength;
192+
} else {
193+
inputOrTextArea.removeAttribute('maxlength');
194+
}
195+
196+
return {
197+
validity: inputOrTextArea.validity,
198+
validationMessage: inputOrTextArea.validationMessage,
199+
};
200+
}
201+
202+
protected override equals(
203+
{state: prev}: TextFieldState,
204+
{state: next}: TextFieldState,
205+
) {
206+
// Check shared input and textarea properties
207+
const inputOrTextAreaEqual =
208+
prev.type === next.type &&
209+
prev.value === next.value &&
210+
prev.required === next.required &&
211+
prev.minLength === next.minLength &&
212+
prev.maxLength === next.maxLength;
213+
214+
if (!isInputState(prev) || !isInputState(next)) {
215+
// Both are textareas, all relevant properties are equal.
216+
return inputOrTextAreaEqual;
217+
}
218+
219+
// Check additional input-specific properties.
220+
return (
221+
inputOrTextAreaEqual &&
222+
prev.pattern === next.pattern &&
223+
prev.min === next.min &&
224+
prev.max === next.max &&
225+
prev.step === next.step
226+
);
227+
}
228+
229+
protected override copy({state}: TextFieldState): TextFieldState {
230+
// Don't hold a reference to the rendered control when copying since we
231+
// don't use it when checking if the state changed.
232+
return {
233+
state: isInputState(state)
234+
? this.copyInput(state)
235+
: this.copyTextArea(state),
236+
renderedControl: null,
237+
};
238+
}
239+
240+
private copyInput(state: InputState): InputState {
241+
const {type, pattern, min, max, step} = state;
242+
return {
243+
...this.copySharedState(state),
244+
type,
245+
pattern,
246+
min,
247+
max,
248+
step,
249+
};
250+
}
251+
252+
private copyTextArea(state: TextAreaState): TextAreaState {
253+
return {
254+
...this.copySharedState(state),
255+
type: state.type,
256+
};
257+
}
258+
259+
private copySharedState({
260+
value,
261+
required,
262+
minLength,
263+
maxLength,
264+
}: SharedInputAndTextAreaState): SharedInputAndTextAreaState {
265+
return {value, required, minLength, maxLength};
266+
}
267+
}
268+
269+
function isInputState(state: InputState | TextAreaState): state is InputState {
270+
return state.type !== 'textarea';
271+
}

0 commit comments

Comments
 (0)