Merge pull request #4635 from JorikSchellekens/joriks/field-ts

Move Field to Typescript
This commit is contained in:
Jorik Schellekens 2020-05-26 16:13:03 +01:00 committed by GitHub
commit 076a3e058d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 632 additions and 535 deletions

View file

@ -36,7 +36,7 @@ interface IProps {
labelStrongPassword?: string;
labelAllowedButUnsafe?: string;
onChange(ev: KeyboardEvent);
onChange(ev: React.FormEvent<HTMLElement>);
onValidate(result: IValidationResult);
}

View file

@ -15,10 +15,10 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from '../../../index';
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;
@ -29,58 +29,93 @@ function getId() {
return `${BASE_ID}_${count++}`;
}
export default class Field extends React.PureComponent {
static propTypes = {
// The field's ID, which binds the input and label together. Immutable.
id: PropTypes.string,
// The element to create. Defaults to "input".
// To define options for a select, use <Field><option ... /></Field>
element: PropTypes.oneOf(["input", "select", "textarea"]),
// The field's type (when used as an <input>). Defaults to "text".
type: PropTypes.string,
// id of a <datalist> element for suggestions
list: PropTypes.string,
// The field's label string.
label: PropTypes.string,
// The field's placeholder string. Defaults to the label.
placeholder: PropTypes.string,
// The field's value.
// This is a controlled component, so the value is required.
value: PropTypes.string.isRequired,
// Optional component to include inside the field before the input.
prefix: PropTypes.node,
// Optional component to include inside the field after the input.
postfix: PropTypes.node,
// 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: PropTypes.func,
// If specified, overrides the value returned by onValidate.
flagInvalid: PropTypes.bool,
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent: PropTypes.node,
// If specified alongside tooltipContent, the class name to apply to the
// tooltip itself.
tooltipClassName: PropTypes.string,
// If specified, an additional class name to apply to the field container
className: PropTypes.string,
// All other props pass through to the <input>.
};
interface IProps extends React.InputHTMLAttributes<HTMLSelectElement | HTMLInputElement> {
// 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>
element?: "input" | " select" | "textarea",
// 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.
onValidate?: (input: IFieldState) => Promise<IValidationResult>,
// 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,
}
export default class Field extends React.PureComponent<IProps, IState> {
private id: string;
private input: HTMLInputElement;
private static defaultProps = {
element: "input",
type: "text",
}
/*
* 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);
constructor(props) {
super(props);
this.state = {
valid: undefined,
feedback: undefined,
feedbackVisible: false,
focused: false,
};
this.id = this.props.id || getId();
}
onFocus = (ev) => {
public focus() {
this.input.focus();
}
private onFocus = (ev) => {
this.setState({
focused: true,
});
@ -93,7 +128,7 @@ export default class Field extends React.PureComponent {
}
};
onChange = (ev) => {
private onChange = (ev) => {
this.validateOnChange();
// Parent component may have supplied its own `onChange` as well
if (this.props.onChange) {
@ -101,7 +136,7 @@ export default class Field extends React.PureComponent {
}
};
onBlur = (ev) => {
private onBlur = (ev) => {
this.setState({
focused: false,
});
@ -114,11 +149,7 @@ export default class Field extends React.PureComponent {
}
};
focus() {
this.input.focus();
}
async validate({ focused, allowEmpty = true }) {
private async validate({ focused, allowEmpty = true }: {focused: boolean, allowEmpty?: boolean}) {
if (!this.props.onValidate) {
return;
}
@ -149,56 +180,42 @@ export default class Field extends React.PureComponent {
}
}
/*
* 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.
*/
validateOnChange = debounce(() => {
this.validate({
focused: true,
});
}, VALIDATION_THROTTLE_MS);
render() {
public render() {
const {
element, prefix, postfix, className, onValidate, children,
element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
const inputElement = element || "input";
// Set some defaults for the <input> element
inputProps.type = inputProps.type || "text";
inputProps.ref = input => this.input = input;
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;
inputProps.list = list;
const fieldInput = React.createElement(inputElement, inputProps, children);
// Appease typescript's inference
const inputProps_ = {...inputProps, ref, list};
const fieldInput = React.createElement(this.props.element, inputProps_, children);
let prefixContainer = null;
if (prefix) {
prefixContainer = <span className="mx_Field_prefix">{prefix}</span>;
if (prefixComponent) {
prefixContainer = <span className="mx_Field_prefix">{prefixComponent}</span>;
}
let postfixContainer = null;
if (postfix) {
postfixContainer = <span className="mx_Field_postfix">{postfix}</span>;
if (postfixComponent) {
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
}
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
const fieldClasses = classNames("mx_Field", `mx_Field_${inputElement}`, className, {
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: prefix,
mx_Field_labelAlwaysTopLeft: prefixComponent,
mx_Field_valid: onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag
? flagInvalid

View file

@ -2,7 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,67 +18,68 @@ limitations under the License.
*/
import React from 'react';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import dis from '../../../dispatcher/dispatcher';
import classNames from 'classnames';
import { ViewTooltipPayload } from '../../../dispatcher/payloads/ViewTooltipPayload';
import { Action } from '../../../dispatcher/actions';
const MIN_TOOLTIP_HEIGHT = 25;
export default createReactClass({
displayName: 'Tooltip',
propTypes: {
interface IProps {
// Class applied to the element used to position the tooltip
className: PropTypes.string,
className: string,
// Class applied to the tooltip itself
tooltipClassName: PropTypes.string,
tooltipClassName?: string,
// Whether the tooltip is visible or hidden.
// The hidden state allows animating the tooltip away via CSS.
// Defaults to visible if unset.
visible: PropTypes.bool,
visible?: boolean,
// the react element to put into the tooltip
label: PropTypes.node,
},
label: React.ReactNode,
}
getDefaultProps() {
return {
visible: true,
};
},
export default class Tooltip extends React.Component<IProps> {
private tooltipContainer: HTMLElement;
private tooltip: void | Element | Component<Element, any, any>;
private parent: Element;
public static readonly defaultProps = {
visible: true,
};
// Create a wrapper for the tooltip outside the parent and attach it to the body element
componentDidMount: function() {
public componentDidMount() {
this.tooltipContainer = document.createElement("div");
this.tooltipContainer.className = "mx_Tooltip_wrapper";
document.body.appendChild(this.tooltipContainer);
window.addEventListener('scroll', this._renderTooltip, true);
window.addEventListener('scroll', this.renderTooltip, true);
this.parent = ReactDOM.findDOMNode(this).parentNode;
this.parent = ReactDOM.findDOMNode(this).parentNode as Element;
this._renderTooltip();
},
this.renderTooltip();
}
componentDidUpdate: function() {
this._renderTooltip();
},
public componentDidUpdate() {
this.renderTooltip();
}
// Remove the wrapper element, as the tooltip has finished using it
componentWillUnmount: function() {
dis.dispatch({
action: 'view_tooltip',
public componentWillUnmount() {
dis.dispatch<ViewTooltipPayload>({
action: Action.ViewTooltip,
tooltip: null,
parent: null,
});
ReactDOM.unmountComponentAtNode(this.tooltipContainer);
document.body.removeChild(this.tooltipContainer);
window.removeEventListener('scroll', this._renderTooltip, true);
},
window.removeEventListener('scroll', this.renderTooltip, true);
}
_updatePosition(style) {
private updatePosition(style: {[key: string]: any}) {
const parentBox = this.parent.getBoundingClientRect();
let offset = 0;
if (parentBox.height > MIN_TOOLTIP_HEIGHT) {
@ -91,16 +92,15 @@ export default createReactClass({
style.top = (parentBox.top - 2) + window.pageYOffset + offset;
style.left = 6 + parentBox.right + window.pageXOffset;
return style;
},
}
_renderTooltip: function() {
private renderTooltip() {
// Add the parent's position to the tooltips, so it's correctly
// positioned, also taking into account any window zoom
// NOTE: The additional 6 pixels for the left position, is to take account of the
// tooltips chevron
const parent = ReactDOM.findDOMNode(this).parentNode;
let style = {};
style = this._updatePosition(style);
const parent = ReactDOM.findDOMNode(this).parentNode as Element;
const style = this.updatePosition({});
// Hide the entire container when not visible. This prevents flashing of the tooltip
// if it is not meant to be visible on first mount.
style.display = this.props.visible ? "block" : "none";
@ -118,21 +118,21 @@ export default createReactClass({
);
// Render the tooltip manually, as we wish it not to be rendered within the parent
this.tooltip = ReactDOM.render(tooltip, this.tooltipContainer);
this.tooltip = ReactDOM.render<Element>(tooltip, this.tooltipContainer);
// Tell the roomlist about us so it can manipulate us if it wishes
dis.dispatch({
action: 'view_tooltip',
dis.dispatch<ViewTooltipPayload>({
action: Action.ViewTooltip,
tooltip: this.tooltip,
parent: parent,
});
},
}
render: function() {
public render() {
// Render a placeholder
return (
<div className={this.props.className} >
</div>
);
},
});
}
}

View file

@ -214,7 +214,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
let tooltip = null;
if (false) { // isCollapsed
if (this.state.hover) {
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto"/>
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} />
}
}

View file

@ -267,7 +267,7 @@ export default class PhoneNumbers extends React.Component {
label={_t("Phone Number")}
autoComplete="off"
disabled={this.state.verifying}
prefix={phoneCountry}
prefixComponent={phoneCountry}
value={this.state.newPhoneNumber}
onChange={this._onChangeNewPhoneNumber}
/>