/** * Copyright (c) 2021 OpenLens Authors * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import "./input.scss"; import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react"; import { boundMethod, cssNames, debouncePromise, getRandId } from "../../utils"; import { Icon } from "../icon"; import { Tooltip, TooltipProps } from "../tooltip"; import * as Validators from "./input_validators"; import type { InputValidator } from "./input_validators"; import isFunction from "lodash/isFunction"; import isBoolean from "lodash/isBoolean"; import uniqueId from "lodash/uniqueId"; import { debounce } from "lodash"; const { conditionalValidators, ...InputValidators } = Validators; export { InputValidators }; export type { InputValidator }; type InputElement = HTMLInputElement | HTMLTextAreaElement; type InputElementProps = InputHTMLAttributes & TextareaHTMLAttributes & DOMAttributes; export interface IconDataFnArg { isDirty: boolean; } /** * One of the folloing: * - A material icon name * - A react node * - Or a function that produces a react node */ export type IconData = string | React.ReactNode | ((opt: IconDataFnArg) => React.ReactNode); export type InputProps = Omit & { theme?: "round-black" | "round"; className?: string; value?: string; trim?: boolean; autoSelectOnFocus?: boolean; defaultValue?: string; multiLine?: boolean; // use text-area as input field maxRows?: number; // when multiLine={true} define max rows size dirty?: boolean; // show validation errors even if the field wasn't touched yet showValidationLine?: boolean; // show animated validation line for async validators showErrorsAsTooltip?: boolean | Omit; // show validation errors as a tooltip :hover (instead of block below) iconLeft?: IconData; iconRight?: IconData; contentRight?: string | React.ReactNode; // Any component of string goes after iconRight validators?: InputValidator | InputValidator[]; onChange?(value: string, evt: React.ChangeEvent): void; onSubmit?(value: string, evt: React.KeyboardEvent): void; }; interface State { focused: boolean; dirty: boolean; valid: boolean; validating: boolean; errors: React.ReactNode[]; submitted: boolean; } const defaultProps: Partial = { rows: 1, maxRows: 10000, showValidationLine: true, validators: [], }; export class Input extends React.Component { static defaultProps = defaultProps as object; public input: InputElement; public validators: InputValidator[] = []; public state: State = { focused: false, valid: true, validating: false, dirty: !!this.props.dirty, errors: [], submitted: false, }; setValue(value = "") { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; nativeInputValueSetter.call(this.input, value); const evt = new Event("input", { bubbles: true }); this.input.dispatchEvent(evt); } } getValue(): string { const { trim, value, defaultValue } = this.props; const rawValue = value ?? this.input?.value ?? defaultValue ?? ""; return trim ? rawValue.trim() : rawValue; } focus() { this.input.focus(); } blur() { this.input.blur(); } select() { this.input.select(); } private autoFitHeight() { const { multiLine, rows, maxRows } = this.props; if (!multiLine) { return; } const textArea = this.input; const lineHeight = parseFloat(window.getComputedStyle(textArea).lineHeight); const rowsCount = (this.getValue().match(/\n/g) || []).length + 1; const height = lineHeight * Math.min(Math.max(rowsCount, rows), maxRows); textArea.style.height = `${height}px`; } private validationId: string; async validate() { const value = this.getValue(); let validationId = (this.validationId = ""); // reset every time for async validators const asyncValidators: Promise[] = []; const errors: React.ReactNode[] = []; // run validators for (const validator of this.validators) { if (errors.length) { // stop validation check if there is an error already break; } const result = validator.validate(value, this.props); if (isBoolean(result) && !result) { errors.push(this.getValidatorError(value, validator)); } else if (result instanceof Promise) { if (!validationId) { this.validationId = validationId = uniqueId("validation_id_"); } asyncValidators.push( result.then( () => null, // don't consider any valid result from promise since we interested in errors only error => this.getValidatorError(value, validator) || error ) ); } } // save sync validators result first this.setValidation(errors); // handle async validators result if (asyncValidators.length > 0) { this.setState({ validating: true, valid: false }); const asyncErrors = await Promise.all(asyncValidators); if (this.validationId === validationId) { this.setValidation(errors.concat(...asyncErrors.filter(err => err))); } } this.input.setCustomValidity(errors.length ? errors[0].toString() : ""); } setValidation(errors: React.ReactNode[]) { this.setState({ validating: false, valid: !errors.length, errors, }); } private getValidatorError(value: string, { message }: InputValidator) { if (isFunction(message)) return message(value, this.props); return message || ""; } private setupValidators() { this.validators = conditionalValidators // add conditional validators if matches input props .filter(validator => validator.condition(this.props)) // add custom validators .concat(this.props.validators) // debounce async validators .map(({ debounce, ...validator }) => { if (debounce) validator.validate = debouncePromise(validator.validate, debounce); return validator; }); // run validation this.validate(); } setDirty(dirty = true) { this.setState({ dirty }); } @boundMethod onFocus(evt: React.FocusEvent) { const { onFocus, autoSelectOnFocus } = this.props; onFocus?.(evt); if (autoSelectOnFocus) this.select(); this.setState({ focused: true }); } @boundMethod onBlur(evt: React.FocusEvent) { this.props.onBlur?.(evt); this.setState({ focused: false }); } setDirtyOnChange = debounce(() => this.setDirty(), 500); @boundMethod onChange(evt: React.ChangeEvent) { this.props.onChange?.(evt.currentTarget.value, evt); this.validate(); this.autoFitHeight(); this.setDirtyOnChange(); // re-render component when used as uncontrolled input // when used @defaultValue instead of @value changing real input.value doesn't call render() if (this.isUncontrolled && this.showMaxLenIndicator) { this.forceUpdate(); } } @boundMethod onKeyDown(evt: React.KeyboardEvent) { this.props.onKeyDown?.(evt); if (evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey || evt.repeat) { return; } if (evt.key === "Enter") { if (this.state.valid) { this.props.onSubmit?.(this.getValue(), evt); this.setDirtyOnChange.cancel(); this.setState({ submitted: true }); if (this.input && typeof this.props.value !== "string") { this.input.value = ""; } } else { this.setDirty(); } } } get showMaxLenIndicator() { const { maxLength, multiLine } = this.props; return maxLength && multiLine; } get isUncontrolled() { return this.props.value === undefined; } componentDidMount() { this.setupValidators(); this.autoFitHeight(); } componentDidUpdate(prevProps: InputProps) { const { defaultValue, value, dirty, validators } = this.props; if (prevProps.value !== value || defaultValue !== prevProps.defaultValue) { if (!this.state.submitted) { this.validate(); } else { this.setState({ submitted: false }); } this.autoFitHeight(); } if (prevProps.dirty !== dirty) { this.setDirty(dirty); } if (prevProps.validators !== validators) { this.setupValidators(); } } get themeSelection(): Record { const { theme } = this.props; if (!theme) { return {}; } return { theme: true, round: true, black: theme === "round-black", }; } @boundMethod bindRef(elem: InputElement) { this.input = elem; } private renderIcon(iconData: IconData) { if (typeof iconData === "string") { return ; } if (typeof iconData === "function") { return iconData({ isDirty: Boolean(this.getValue()), }); } return iconData; } render() { const { multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip, maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id, dirty: _dirty, // excluded from passing to input-element defaultValue, trim, ...inputProps } = this.props; const { focused, dirty, valid, validating, errors } = this.state; const className = cssNames("Input", this.props.className, { ...this.themeSelection, focused, disabled, invalid: !valid, dirty, validating, validatingLine: validating && showValidationLine, }); // prepare input props Object.assign(inputProps, { className: "input box grow", onFocus: this.onFocus, onBlur: this.onBlur, onChange: this.onChange, onKeyDown: this.onKeyDown, rows: multiLine ? (rows || 1) : null, ref: this.bindRef, spellCheck: "false", disabled, }); const showErrors = errors.length > 0 && !valid && dirty; const errorsInfo = (
{errors.map((error, i) =>

{error}

)}
); const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; let tooltipError: React.ReactNode; if (showErrorsAsTooltip && showErrors) { const tooltipProps = typeof showErrorsAsTooltip === "object" ? showErrorsAsTooltip : {}; tooltipProps.className = cssNames("InputTooltipError", tooltipProps.className); tooltipError = (
{errorsInfo}
); } return (
{tooltipError}