This repository was archived by the owner on Oct 22, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 10
/
Copy pathField.tsx
344 lines (306 loc) · 12.1 KB
/
Field.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
/*
Copyright 2019-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, {
InputHTMLAttributes,
SelectHTMLAttributes,
TextareaHTMLAttributes,
RefObject,
createRef,
KeyboardEvent,
} from "react";
import classNames from "classnames";
import { debounce } from "lodash";
import { IFieldState, IValidationResult } from "./Validation";
import Tooltip, { Alignment } from "./Tooltip";
import { Key } from "../../../Keyboard";
// Invoke validation from user input (when typing, etc.) at most once every N ms.
const VALIDATION_THROTTLE_MS = 200;
const BASE_ID = "mx_Field";
let count = 1;
function getId(): string {
return `${BASE_ID}_${count++}`;
}
export interface IValidateOpts {
focused?: boolean;
allowEmpty?: boolean;
}
interface IProps {
// The field's ID, which binds the input and label together. Immutable.
id?: string;
// The field's label string.
label?: string;
// The field's placeholder string. Defaults to the label.
placeholder?: string;
// When true (default false), the placeholder will be shown instead of the label when
// the component is unfocused & empty.
usePlaceholderAsHint?: boolean;
// Optional component to include inside the field before the input.
prefixComponent?: React.ReactNode;
// Optional component to include inside the field after the input.
postfixComponent?: React.ReactNode;
// The callback called whenever the contents of the field
// changes. Returns an object with `valid` boolean field
// and a `feedback` react component field to provide feedback
// to the user.
onValidate?: (input: IFieldState) => Promise<IValidationResult>;
// If specified, overrides the value returned by onValidate.
forceValidity?: boolean;
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent?: React.ReactNode;
// If specified the tooltip will be shown regardless of feedback
forceTooltipVisible?: boolean;
// If specified, the tooltip with be aligned accorindly with the field, defaults to Right.
tooltipAlignment?: Alignment;
// If specified alongside tooltipContent, the class name to apply to the
// tooltip itself.
tooltipClassName?: string;
// If specified, an additional class name to apply to the field container
className?: string;
// On what events should validation occur; by default on all
validateOnFocus?: boolean;
validateOnBlur?: boolean;
validateOnChange?: boolean;
// All other props pass through to the <input>.
}
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The ref pass through to the input
inputRef?: RefObject<HTMLInputElement>;
// The element to create. Defaults to "input".
element: "input";
// The input's value. This is a controlled component, so the value is required.
value: string;
}
interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
// The ref pass through to the select
inputRef?: RefObject<HTMLSelectElement>;
// To define options for a select, use <Field><option ... /></Field>
element: "select";
// The select's value. This is a controlled component, so the value is required.
value: string;
}
interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElement> {
// The ref pass through to the textarea
inputRef?: RefObject<HTMLTextAreaElement>;
element: "textarea";
// The textarea's value. This is a controlled component, so the value is required.
value: string;
}
export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The ref pass through to the input
inputRef?: RefObject<HTMLInputElement>;
element: "input";
// The input's value. This is a controlled component, so the value is required.
value: string;
}
type PropShapes = IInputProps | ISelectProps | ITextareaProps | INativeOnChangeInputProps;
interface IState {
valid?: boolean;
feedback?: React.ReactNode;
feedbackVisible: boolean;
focused: boolean;
}
export default class Field extends React.PureComponent<PropShapes, IState> {
private readonly id: string;
private readonly _inputRef = createRef<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>();
public static readonly defaultProps = {
element: "input",
type: "text",
validateOnFocus: true,
validateOnBlur: true,
validateOnChange: true,
};
/*
* This was changed from throttle to debounce: this is more traditional for
* form validation since it means that the validation doesn't happen at all
* until the user stops typing for a bit (debounce defaults to not running on
* the leading edge). If we're doing an HTTP hit on each validation, we have more
* incentive to prevent validating input that's very unlikely to be valid.
* We may find that we actually want different behaviour for registration
* fields, in which case we can add some options to control it.
*/
private validateOnChange = debounce(() => {
this.validate({
focused: true,
});
}, VALIDATION_THROTTLE_MS);
public constructor(props: PropShapes) {
super(props);
this.state = {
feedbackVisible: false,
focused: false,
};
this.id = this.props.id || getId();
}
public focus(): void {
this.inputRef.current?.focus();
// programmatic does not fire onFocus handler
this.setState({
focused: true,
});
}
private onFocus = (ev: React.FocusEvent<any>): void => {
this.setState({
focused: true,
});
if (this.props.validateOnFocus) {
this.validate({
focused: true,
});
}
// Parent component may have supplied its own `onFocus` as well
this.props.onFocus?.(ev);
};
private onChange = (ev: React.ChangeEvent<any>): void => {
if (this.props.validateOnChange) {
this.validateOnChange();
}
// Parent component may have supplied its own `onChange` as well
this.props.onChange?.(ev);
};
private onBlur = (ev: React.FocusEvent<any>): void => {
this.setState({
focused: false,
});
if (this.props.validateOnBlur) {
this.validate({
focused: false,
});
}
// Parent component may have supplied its own `onBlur` as well
this.props.onBlur?.(ev);
};
public async validate({ focused, allowEmpty = true }: IValidateOpts): Promise<boolean | undefined> {
if (!this.props.onValidate) {
return;
}
const value = this.inputRef.current?.value ?? null;
const { valid, feedback } = await this.props.onValidate({
value,
focused: !!focused,
allowEmpty,
});
// this method is async and so we may have been blurred since the method was called
// if we have then hide the feedback as withValidation does
if (this.state.focused && feedback) {
this.setState({
valid,
feedback,
feedbackVisible: true,
});
} else {
// When we receive null `feedback`, we want to hide the tooltip.
// We leave the previous `feedback` content in state without updating it,
// so that we can hide the tooltip containing the most recent feedback
// via CSS animation.
this.setState({
valid,
feedbackVisible: false,
});
}
return valid;
}
private get inputRef(): RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> {
return this.props.inputRef ?? this._inputRef;
}
private onKeyDown = (evt: KeyboardEvent<HTMLDivElement>): void => {
// If the tooltip is displayed to show a feedback and Escape is pressed
// The tooltip is hided
if (this.state.feedbackVisible && evt.key === Key.ESCAPE) {
evt.preventDefault();
evt.stopPropagation();
this.setState({
feedbackVisible: false,
});
}
};
public render(): React.ReactNode {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const {
element,
inputRef,
prefixComponent,
postfixComponent,
className,
onValidate,
children,
tooltipContent,
forceValidity,
tooltipClassName,
validateOnBlur,
validateOnChange,
validateOnFocus,
usePlaceholderAsHint,
forceTooltipVisible,
tooltipAlignment,
...inputProps
} = this.props;
// Handle displaying feedback on validity
let fieldTooltip: JSX.Element | undefined;
if (tooltipContent || this.state.feedback) {
const tooltipId = `${this.id}_tooltip`;
const visible = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible;
if (visible) {
inputProps["aria-describedby"] = tooltipId;
}
let role: React.AriaRole;
if (tooltipContent) {
role = "tooltip";
} else {
role = this.state.valid ? "status" : "alert";
}
fieldTooltip = (
<Tooltip
id={tooltipId}
tooltipClassName={classNames("mx_Field_tooltip", "mx_Tooltip_noMargin", tooltipClassName)}
visible={visible}
label={tooltipContent || this.state.feedback}
alignment={tooltipAlignment || Alignment.Right}
role={role}
/>
);
}
inputProps.placeholder = inputProps.placeholder ?? inputProps.label;
inputProps.id = this.id; // this overwrites the id from props
inputProps.onFocus = this.onFocus;
inputProps.onChange = this.onChange;
inputProps.onBlur = this.onBlur;
// Appease typescript's inference
const inputProps_: React.HTMLAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> &
React.ClassAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> = {
...inputProps,
ref: this.inputRef,
};
const fieldInput = React.createElement(this.props.element, inputProps_, children);
let prefixContainer: JSX.Element | undefined;
if (prefixComponent) {
prefixContainer = <span className="mx_Field_prefix">{prefixComponent}</span>;
}
let postfixContainer: JSX.Element | undefined;
if (postfixComponent) {
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
}
const hasValidationFlag = forceValidity !== null && forceValidity !== undefined;
const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, {
// If we have a prefix element, leave the label always at the top left and
// don't animate it, as it looks a bit clunky and would add complexity to do
// properly.
mx_Field_labelAlwaysTopLeft: prefixComponent || usePlaceholderAsHint,
mx_Field_placeholderIsHint: usePlaceholderAsHint,
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag ? !forceValidity : onValidate && this.state.valid === false,
});
return (
<div className={fieldClasses} onKeyDown={this.onKeyDown}>
{prefixContainer}
{fieldInput}
<label htmlFor={this.id}>{this.props.label}</label>
{postfixContainer}
{fieldTooltip}
</div>
);
}
}