Files
ThreadNet-Web/src/components/views/elements/Field.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

349 lines
12 KiB
TypeScript
Raw Normal View History

2019-01-19 22:11:20 -06:00
/*
2024-09-09 14:57:16 +01:00
Copyright 2019-2024 New Vector Ltd.
2019-01-19 22:11:20 -06:00
2024-09-09 14:57:16 +01:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
2019-01-19 22:11:20 -06:00
*/
import React, {
InputHTMLAttributes,
SelectHTMLAttributes,
TextareaHTMLAttributes,
RefObject,
createRef,
ComponentProps,
2024-11-13 16:35:02 +05:30
MutableRefObject,
RefCallback,
2024-11-13 20:39:17 +05:30
Ref,
} from "react";
2019-03-05 15:13:12 +00:00
import classNames from "classnames";
2021-06-29 13:11:58 +01:00
import { debounce } from "lodash";
import { Tooltip } from "@vector-im/compound-web";
2021-10-22 17:23:32 -05:00
2021-06-29 13:11:58 +01:00
import { IFieldState, IValidationResult } from "./Validation";
// Invoke validation from user input (when typing, etc.) at most once every N ms.
const VALIDATION_THROTTLE_MS = 200;
2019-01-19 22:11:20 -06:00
const BASE_ID = "mx_Field";
let count = 1;
function getId(): string {
return `${BASE_ID}_${count++}`;
}
2021-06-07 08:54:41 +01:00
export interface IValidateOpts {
focused?: boolean;
allowEmpty?: boolean;
}
2020-06-17 02:14:20 +01:00
interface IProps {
2020-05-25 13:40:05 +01:00
// The field's ID, which binds the input and label together. Immutable.
2020-06-17 02:14:20 +01:00
id?: string;
2020-05-25 13:40:05 +01:00
// The field's label string.
2020-06-17 02:14:20 +01:00
label?: string;
2020-05-25 13:40:05 +01:00
// The field's placeholder string. Defaults to the label.
2020-06-17 02:14:20 +01:00
placeholder?: string;
2021-11-01 23:44:42 -06:00
// When true (default false), the placeholder will be shown instead of the label when
// the component is unfocused & empty.
usePlaceholderAsHint?: boolean;
2020-05-25 13:40:05 +01:00
// Optional component to include inside the field before the input.
2020-06-17 02:14:20 +01:00
prefixComponent?: React.ReactNode;
2020-05-25 13:40:05 +01:00
// Optional component to include inside the field after the input.
2020-06-17 02:14:20 +01:00
postfixComponent?: React.ReactNode;
2020-05-25 13:40:05 +01:00
// 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.
2020-06-17 02:14:20 +01:00
onValidate?: (input: IFieldState) => Promise<IValidationResult>;
2020-05-25 13:40:05 +01:00
// If specified, overrides the value returned by onValidate.
forceValidity?: boolean;
2020-05-25 13:40:05 +01:00
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent?: JSX.Element | string;
2020-06-15 17:42:30 +01:00
// If specified the tooltip will be shown regardless of feedback
2020-06-22 11:39:11 +01:00
forceTooltipVisible?: boolean;
// If specified, the tooltip with be aligned accorindly with the field, defaults to Right.
tooltipAlignment?: ComponentProps<typeof Tooltip>["placement"];
2020-05-25 13:40:05 +01:00
// If specified alongside tooltipContent, the class name to apply to the
// tooltip itself.
2020-06-17 02:14:20 +01:00
tooltipClassName?: string;
2020-05-25 13:40:05 +01:00
// If specified, an additional class name to apply to the field container
2020-06-17 02:14:20 +01:00
className?: string;
// On what events should validation occur; by default on all
validateOnFocus?: boolean;
validateOnBlur?: boolean;
validateOnChange?: boolean;
2020-05-25 13:40:05 +01:00
// All other props pass through to the <input>.
}
2019-01-22 13:09:40 -07:00
2020-11-19 15:10:40 +00:00
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The ref pass through to the input
2024-11-13 20:39:17 +05:30
inputRef?: Ref<HTMLInputElement>;
2020-06-17 02:14:20 +01:00
// The element to create. Defaults to "input".
element: "input";
2020-06-17 02:14:20 +01:00
// 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
2024-11-13 20:39:17 +05:30
inputRef?: Ref<HTMLSelectElement>;
2020-06-17 02:14:20 +01:00
// 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
2024-11-13 20:39:17 +05:30
inputRef?: Ref<HTMLTextAreaElement>;
2020-06-17 02:14:20 +01:00
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
2024-11-13 20:39:17 +05:30
inputRef?: Ref<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;
2020-06-17 02:14:20 +01:00
2020-05-25 13:40:05 +01:00
interface IState {
valid?: boolean;
feedback?: JSX.Element | string;
2020-06-18 14:32:43 +01:00
feedbackVisible: boolean;
focused: boolean;
2020-05-25 13:40:05 +01:00
}
2020-06-17 02:14:20 +01:00
export default class Field extends React.PureComponent<PropShapes, IState> {
private readonly id: string;
2024-11-13 16:35:02 +05:30
private readonly _inputRef: MutableRefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null> =
createRef();
/**
* When props.inputRef is a callback ref, we will pass callbackRef to the DOM element.
* This is so that other methods here can still access the DOM element via this._inputRef.
*/
private readonly callbackRef: RefCallback<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = (node) => {
this._inputRef.current = node;
(this.props.inputRef as RefCallback<unknown>)(node);
};
2020-05-25 13:40:05 +01:00
2020-06-18 14:32:43 +01:00
public static readonly defaultProps = {
2020-05-25 16:47:57 +01:00
element: "input",
type: "text",
validateOnFocus: true,
validateOnBlur: true,
validateOnChange: true,
tooltipAlignment: "right",
2020-06-18 14:32:43 +01:00
};
2020-05-25 16:47:57 +01:00
2020-05-25 13:40:05 +01:00
/*
* 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.
*/
2020-05-26 12:09:23 +01:00
private validateOnChange = debounce(() => {
2020-05-25 13:40:05 +01:00
this.validate({
focused: true,
});
}, VALIDATION_THROTTLE_MS);
2023-02-13 11:39:16 +00:00
public constructor(props: PropShapes) {
2020-03-29 23:16:57 +01:00
super(props);
2019-02-01 00:36:19 +01:00
this.state = {
2020-05-25 13:40:05 +01:00
feedbackVisible: false,
focused: false,
2019-02-01 00:36:19 +01:00
};
this.id = this.props.id || getId();
2019-02-01 00:36:19 +01:00
}
public focus(): void {
this.inputRef.current?.focus();
// programmatic does not fire onFocus handler
this.setState({
focused: true,
});
2020-05-26 12:09:23 +01:00
}
2023-02-13 11:39:16 +00:00
private onFocus = (ev: React.FocusEvent<any>): void => {
this.setState({
focused: true,
});
if (this.props.validateOnFocus) {
this.validate({
focused: true,
});
}
2019-04-16 18:12:13 +01:00
// Parent component may have supplied its own `onFocus` as well
2023-02-13 11:39:16 +00:00
this.props.onFocus?.(ev);
2019-04-16 18:12:13 +01:00
};
2023-02-13 11:39:16 +00:00
private onChange = (ev: React.ChangeEvent<any>): void => {
if (this.props.validateOnChange) {
this.validateOnChange();
}
// Parent component may have supplied its own `onChange` as well
2023-02-13 11:39:16 +00:00
this.props.onChange?.(ev);
2019-02-01 00:36:19 +01:00
};
2023-02-13 11:39:16 +00:00
private onBlur = (ev: React.FocusEvent<any>): void => {
this.setState({
focused: false,
});
if (this.props.validateOnBlur) {
this.validate({
focused: false,
});
}
2019-04-16 18:12:13 +01:00
// Parent component may have supplied its own `onBlur` as well
2023-02-13 11:39:16 +00:00
this.props.onBlur?.(ev);
2019-04-16 18:12:13 +01:00
};
public async validate({ focused, allowEmpty = true }: IValidateOpts): Promise<boolean | undefined> {
2019-04-16 18:12:13 +01:00
if (!this.props.onValidate) {
return;
}
const value = this.inputRef.current?.value ?? null;
const { valid, feedback } = await this.props.onValidate({
2019-04-16 18:12:13 +01:00
value,
focused: !!focused,
allowEmpty,
2019-04-16 18:12:13 +01:00
});
2019-12-17 14:36:20 +00:00
// 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;
2019-04-16 18:12:13 +01:00
}
private get inputRef(): RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> {
2024-11-13 16:35:02 +05:30
const inputRef = this.props.inputRef;
if (typeof inputRef === "function") {
// This is a callback ref, so return _inputRef which will point to the actual DOM element.
return this._inputRef;
}
return (inputRef ?? this._inputRef) as RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>;
}
private onTooltipOpenChange = (open: boolean): void => {
this.setState({
feedbackVisible: open,
});
};
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,
2021-06-29 13:11:58 +01:00
...inputProps
} = this.props;
2019-01-19 22:11:20 -06:00
// Handle displaying feedback on validity
const tooltipProps: Pick<React.ComponentProps<typeof Tooltip>, "aria-live" | "aria-atomic"> = {};
let tooltipOpen = false;
if (tooltipContent || this.state.feedback) {
tooltipOpen = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible;
if (!tooltipContent) {
tooltipProps["aria-atomic"] = "true";
tooltipProps["aria-live"] = this.state.valid ? "polite" : "assertive";
}
}
inputProps.placeholder = inputProps.placeholder ?? inputProps.label;
inputProps.id = this.id; // this overwrites the id from props
2019-01-22 19:25:09 -07:00
2019-04-16 18:12:13 +01:00
inputProps.onFocus = this.onFocus;
2019-02-01 00:36:19 +01:00
inputProps.onChange = this.onChange;
2019-04-16 18:12:13 +01:00
inputProps.onBlur = this.onBlur;
2020-05-25 13:40:05 +01:00
// Appease typescript's inference
const inputProps_: React.HTMLAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> &
React.ClassAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> = {
...inputProps,
2024-11-13 16:35:02 +05:30
ref: typeof this.props.inputRef === "function" ? this.callbackRef : this.inputRef,
};
2020-05-25 13:40:05 +01:00
const fieldInput = React.createElement(this.props.element, inputProps_, children);
2019-01-19 22:11:20 -06:00
let prefixContainer: JSX.Element | undefined;
2020-05-25 13:40:05 +01:00
if (prefixComponent) {
prefixContainer = <span className="mx_Field_prefix">{prefixComponent}</span>;
2019-03-05 15:13:12 +00:00
}
let postfixContainer: JSX.Element | undefined;
2020-05-25 13:40:05 +01:00
if (postfixComponent) {
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
2019-09-20 17:45:14 +02:00
}
2019-03-05 15:13:12 +00:00
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,
});
2019-03-05 15:13:12 +00:00
2019-02-01 00:36:19 +01:00
return (
<div className={fieldClasses}>
{prefixContainer}
<Tooltip
{...tooltipProps}
placement={tooltipAlignment}
description=""
caption={tooltipContent || this.state.feedback}
open={tooltipOpen}
onOpenChange={this.onTooltipOpenChange}
>
{fieldInput}
</Tooltip>
<label htmlFor={this.id}>{this.props.label}</label>
{postfixContainer}
2019-01-19 22:11:20 -06:00
</div>
);
}
}