element-web/src/components/views/elements/Field.tsx

246 lines
8.5 KiB
TypeScript
Raw Normal View History

/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import * as sdk from '../../../index';
2020-05-25 18:47:57 +03:00
import { debounce } from 'lodash';
import {IFieldState, IValidationResult} from "../elements/Validation";
// 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() {
return `${BASE_ID}_${count++}`;
}
2020-05-25 18:47:57 +03:00
interface IProps extends React.InputHTMLAttributes<HTMLSelectElement | HTMLInputElement> {
2020-05-25 15:40:05 +03:00
// The field's ID, which binds the input and label together. Immutable.
id?: string,
// The element to create. Defaults to "input".
// To define options for a select, use <Field><option ... /></Field>
2020-05-28 15:55:07 +03:00
element?: "input" | "select" | "textarea",
2020-05-25 15:40:05 +03:00
// The field's type (when used as an <input>). Defaults to "text".
type?: string,
// id of a <datalist> element for suggestions
list?: string,
// The field's label string.
label?: string,
// The field's placeholder string. Defaults to the label.
placeholder?: string,
// The field's value.
// This is a controlled component, so the value is required.
value: string,
// 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.
2020-05-25 18:47:57 +03:00
onValidate?: (input: IFieldState) => Promise<IValidationResult>,
2020-05-25 15:40:05 +03:00
// If specified, overrides the value returned by onValidate.
flagInvalid?: boolean,
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent?: React.ReactNode,
// 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,
// All other props pass through to the <input>.
}
interface IState {
valid: boolean,
feedback: React.ReactNode,
feedbackVisible: boolean,
focused: boolean,
}
2020-05-25 15:40:05 +03:00
export default class Field extends React.PureComponent<IProps, IState> {
private id: string;
private input: HTMLInputElement;
private static defaultProps = {
2020-05-25 18:47:57 +03:00
element: "input",
type: "text",
}
2020-05-25 15:40:05 +03: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.
*/
private validateOnChange = debounce(() => {
2020-05-25 15:40:05 +03:00
this.validate({
focused: true,
});
}, VALIDATION_THROTTLE_MS);
constructor(props) {
super(props);
this.state = {
valid: undefined,
feedback: undefined,
2020-05-25 15:40:05 +03:00
feedbackVisible: false,
focused: false,
};
this.id = this.props.id || getId();
}
public focus() {
this.input.focus();
}
private onFocus = (ev) => {
this.setState({
focused: true,
});
this.validate({
focused: true,
});
// Parent component may have supplied its own `onFocus` as well
if (this.props.onFocus) {
this.props.onFocus(ev);
}
};
private onChange = (ev) => {
this.validateOnChange();
// Parent component may have supplied its own `onChange` as well
if (this.props.onChange) {
this.props.onChange(ev);
}
};
private onBlur = (ev) => {
this.setState({
focused: false,
});
this.validate({
focused: false,
});
// Parent component may have supplied its own `onBlur` as well
if (this.props.onBlur) {
this.props.onBlur(ev);
}
};
private async validate({ focused, allowEmpty = true }: {focused: boolean, allowEmpty?: boolean}) {
if (!this.props.onValidate) {
return;
}
const value = this.input ? this.input.value : null;
const { valid, feedback } = await this.props.onValidate({
value,
focused,
allowEmpty,
});
2019-12-17 17:36:20 +03: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,
});
}
}
2020-05-25 15:40:05 +03:00
public render() {
const {
2020-05-25 15:40:05 +03:00
element, prefixComponent, postfixComponent, className, onValidate, children,
2020-03-09 18:31:07 +03:00
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
// Set some defaults for the <input> element
2020-05-25 15:40:05 +03:00
const ref = input => this.input = input;
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;
2020-05-25 15:40:05 +03:00
// Appease typescript's inference
const inputProps_ = {...inputProps, ref, list};
2020-05-25 18:47:57 +03:00
const fieldInput = React.createElement(this.props.element, inputProps_, children);
let prefixContainer = null;
2020-05-25 15:40:05 +03:00
if (prefixComponent) {
prefixContainer = <span className="mx_Field_prefix">{prefixComponent}</span>;
}
let postfixContainer = null;
2020-05-25 15:40:05 +03:00
if (postfixComponent) {
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
}
2019-08-15 22:33:02 +03:00
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
2020-05-25 18:47:57 +03:00
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.
2020-05-25 15:40:05 +03:00
mx_Field_labelAlwaysTopLeft: prefixComponent,
mx_Field_valid: onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag
? flagInvalid
: onValidate && this.state.valid === false,
});
// Handle displaying feedback on validity
const Tooltip = sdk.getComponent("elements.Tooltip");
let fieldTooltip;
2019-08-13 13:01:04 +03:00
if (tooltipContent || this.state.feedback) {
const addlClassName = tooltipClassName ? tooltipClassName : '';
fieldTooltip = <Tooltip
tooltipClassName={`mx_Field_tooltip ${addlClassName}`}
visible={this.state.feedbackVisible}
2019-08-13 13:01:04 +03:00
label={tooltipContent || this.state.feedback}
/>;
}
return <div className={fieldClasses}>
{prefixContainer}
{fieldInput}
<label htmlFor={this.id}>{this.props.label}</label>
{postfixContainer}
{fieldTooltip}
</div>;
}
}