Move tooltip to ts

This commit is contained in:
Jorik Schellekens 2020-05-25 13:40:05 +01:00
parent 7533a8f80b
commit 63f78b0808
7 changed files with 170 additions and 113 deletions

View file

@ -118,9 +118,11 @@
"@peculiar/webcrypto": "^1.0.22", "@peculiar/webcrypto": "^1.0.22",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/lodash": "^4.14.152",
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",
"@types/qrcode": "^1.3.4", "@types/qrcode": "^1.3.4",
"@types/react": "16.9", "@types/react": "16.9",
"@types/react-dom": "^16.9.8",
"@types/zxcvbn": "^4.4.0", "@types/zxcvbn": "^4.4.0",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0", "babel-jest": "^24.9.0",

View file

@ -15,10 +15,9 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { debounce } from 'lodash'; import { debounce, Cancelable } from 'lodash';
// Invoke validation from user input (when typing, etc.) at most once every N ms. // Invoke validation from user input (when typing, etc.) at most once every N ms.
const VALIDATION_THROTTLE_MS = 200; const VALIDATION_THROTTLE_MS = 200;
@ -29,51 +28,88 @@ function getId() {
return `${BASE_ID}_${count++}`; return `${BASE_ID}_${count++}`;
} }
export default class Field extends React.PureComponent { interface IProps extends React.HTMLAttributes<HTMLElement> {
static propTypes = { // The field's ID, which binds the input and label together. Immutable.
// The field's ID, which binds the input and label together. Immutable. id?: string,
id: PropTypes.string, // The element to create. Defaults to "input".
// The element to create. Defaults to "input". // To define options for a select, use <Field><option ... /></Field>
// To define options for a select, use <Field><option ... /></Field> element?: InputType,
element: PropTypes.oneOf(["input", "select", "textarea"]), // The field's type (when used as an <input>). Defaults to "text".
// The field's type (when used as an <input>). Defaults to "text". type?: string,
type: PropTypes.string, // id of a <datalist> element for suggestions
// id of a <datalist> element for suggestions list?: string,
list: PropTypes.string, // The field's label string.
// The field's label string. label?: string,
label: PropTypes.string, // The field's placeholder string. Defaults to the label.
// The field's placeholder string. Defaults to the label. placeholder?: string,
placeholder: PropTypes.string, // The field's value.
// The field's value. // This is a controlled component, so the value is required.
// This is a controlled component, so the value is required. value: string,
value: PropTypes.string.isRequired, // Optional component to include inside the field before the input.
// Optional component to include inside the field before the input. prefixComponent?: React.ReactNode,
prefix: PropTypes.node, // Optional component to include inside the field after the input.
// Optional component to include inside the field after the input. postfixComponent?: React.ReactNode,
postfix: PropTypes.node, // The callback called whenever the contents of the field
// The callback called whenever the contents of the field // changes. Returns an object with `valid` boolean field
// changes. Returns an object with `valid` boolean field // and a `feedback` react component field to provide feedback
// and a `feedback` react component field to provide feedback // to the user.
// to the user. onValidate?: (
onValidate: PropTypes.func, args: {value: string, focused: boolean, allowEmpty: boolean}
// If specified, overrides the value returned by onValidate. ) => {valid: boolean, feedback: React.ReactNode},
flagInvalid: PropTypes.bool, // If specified, overrides the value returned by onValidate.
// If specified, contents will appear as a tooltip on the element and flagInvalid?: boolean,
// validation feedback tooltips will be suppressed. // If specified, contents will appear as a tooltip on the element and
tooltipContent: PropTypes.node, // validation feedback tooltips will be suppressed.
// If specified alongside tooltipContent, the class name to apply to the tooltipContent?: React.ReactNode,
// tooltip itself. // If specified alongside tooltipContent, the class name to apply to the
tooltipClassName: PropTypes.string, // tooltip itself.
// If specified, an additional class name to apply to the field container tooltipClassName?: string,
className: PropTypes.string, // If specified, an additional class name to apply to the field container
// All other props pass through to the <input>. className?: string,
}; // All other props pass through to the <input>.
}
enum InputType {
INPUT = "input",
SELECT = "select",
TEXTAREA = "textarea",
}
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;
/*
* 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);
focus() {
this.input.focus();
}
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
valid: undefined, valid: undefined,
feedback: undefined, feedback: undefined,
feedbackVisible: false,
focused: false, focused: false,
}; };
@ -114,11 +150,7 @@ export default class Field extends React.PureComponent {
} }
}; };
focus() { async validate({ focused, allowEmpty = true }: {focused: boolean, allowEmpty?: boolean}) {
this.input.focus();
}
async validate({ focused, allowEmpty = true }) {
if (!this.props.onValidate) { if (!this.props.onValidate) {
return; return;
} }
@ -149,48 +181,37 @@ 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() { render() {
const { const {
element, prefix, postfix, className, onValidate, children, element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props; tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
const inputElement = element || "input"; const inputElement = element || "input";
// Set some defaults for the <input> element // Set some defaults for the <input> element
inputProps.type = inputProps.type || "text"; inputProps.type = inputProps.type || "text";
inputProps.ref = input => this.input = input; const ref = input => this.input = input;
inputProps.placeholder = inputProps.placeholder || inputProps.label; inputProps.placeholder = inputProps.placeholder || inputProps.label;
inputProps.id = this.id; // this overwrites the id from props inputProps.id = this.id; // this overwrites the id from props
inputProps.onFocus = this.onFocus; inputProps.onFocus = this.onFocus;
inputProps.onChange = this.onChange; inputProps.onChange = this.onChange;
inputProps.onBlur = this.onBlur; 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(inputElement, inputProps_, children);
let prefixContainer = null; let prefixContainer = null;
if (prefix) { if (prefixComponent) {
prefixContainer = <span className="mx_Field_prefix">{prefix}</span>; prefixContainer = <span className="mx_Field_prefix">{prefixComponent}</span>;
} }
let postfixContainer = null; let postfixContainer = null;
if (postfix) { if (postfixComponent) {
postfixContainer = <span className="mx_Field_postfix">{postfix}</span>; postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
} }
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined; const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
@ -198,7 +219,7 @@ export default class Field extends React.PureComponent {
// If we have a prefix element, leave the label always at the top left and // 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 // don't animate it, as it looks a bit clunky and would add complexity to do
// properly. // properly.
mx_Field_labelAlwaysTopLeft: prefix, mx_Field_labelAlwaysTopLeft: prefixComponent,
mx_Field_valid: onValidate && this.state.valid === true, mx_Field_valid: onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag mx_Field_invalid: hasValidationFlag
? flagInvalid ? flagInvalid

View file

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

View file

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

View file

@ -38,5 +38,10 @@ export enum Action {
* Open the user settings. No additional payload information required. * Open the user settings. No additional payload information required.
*/ */
ViewUserSettings = "view_user_settings", ViewUserSettings = "view_user_settings",
/**
* Sets the current tooltip
*/
ViewTooltip = "view_tooltip",
} }

View file

@ -17,6 +17,7 @@ limitations under the License.
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ActionPayload } from "../payloads"; import { ActionPayload } from "../payloads";
import { Action } from "../actions"; import { Action } from "../actions";
import { Component } from "react";
export interface ViewUserPayload extends ActionPayload { export interface ViewUserPayload extends ActionPayload {
action: Action.ViewUser, action: Action.ViewUser,
@ -27,3 +28,19 @@ export interface ViewUserPayload extends ActionPayload {
*/ */
member?: RoomMember; member?: RoomMember;
} }
export interface ViewTooltipPayload extends ActionPayload {
action: Action.ViewTooltip,
/*
* The tooltip to render. If it's null the tooltip will not be rendered
* We need the void type because of typescript headaches.
*/
tooltip: null | void | Element | Component<Element, any, any>;
/*
* The parent under which to render the tooltip. Can be null to remove
* the parent type.
*/
parent: null | Element
}

View file

@ -1265,6 +1265,11 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
"@types/lodash@^4.14.152":
version "4.14.152"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.152.tgz#7e7679250adce14e749304cdb570969f77ec997c"
integrity sha512-Vwf9YF2x1GE3WNeUMjT5bTHa2DqgUo87ocdgTScupY2JclZ5Nn7W2RLM/N0+oreexUk8uaVugR81NnTY/jNNXg==
"@types/minimatch@*": "@types/minimatch@*":
version "3.0.3" version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@ -1292,6 +1297,13 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/react-dom@^16.9.8":
version "16.9.8"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
dependencies:
"@types/react" "*"
"@types/react@*": "@types/react@*":
version "16.9.35" version "16.9.35"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368"