/* * Copyright 2016 Palantir Technologies, Inc. All rights reserved. * * 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 classNames from "classnames"; import * as React from "react"; import { polyfill } from "react-lifecycles-compat"; import { AbstractPureComponent2, Classes, Keys } from "../../common"; import { DISPLAYNAME_PREFIX, IIntentProps, IProps } from "../../common/props"; import { clamp, safeInvoke } from "../../common/utils"; import { Browser } from "../../compatibility"; export interface IEditableTextProps extends IIntentProps, IProps { /** * EXPERIMENTAL FEATURE. * * When true, this forces the component to _always_ render an editable input (or textarea) * both when the component is focussed and unfocussed, instead of the component's default * behavior of switching between a text span and a text input upon interaction. * * This behavior can help in certain applications where, for example, a custom right-click * context menu is used to supply clipboard copy and paste functionality. * @default false */ alwaysRenderInput?: boolean; /** * If `true` and in multiline mode, the `enter` key will trigger onConfirm and `mod+enter` * will insert a newline. If `false`, the key bindings are inverted such that `enter` * adds a newline. * @default false */ confirmOnEnterKey?: boolean; /** Default text value of uncontrolled input. */ defaultValue?: string; /** * Whether the text can be edited. * @default false */ disabled?: boolean; /** Whether the component is currently being edited. */ isEditing?: boolean; /** Maximum number of characters allowed. Unlimited by default. */ maxLength?: number; /** Minimum width in pixels of the input, when not `multiline`. */ minWidth?: number; /** * Whether the component supports multiple lines of text. * This prop should not be changed during the component's lifetime. * @default false */ multiline?: boolean; /** * Maximum number of lines before scrolling begins, when `multiline`. */ maxLines?: number; /** * Minimum number of lines (essentially minimum height), when `multiline`. * @default 1 */ minLines?: number; /** * Placeholder text when there is no value. * @default "Click to Edit" */ placeholder?: string; /** * Whether the entire text field should be selected on focus. * If `false`, the cursor is placed at the end of the text. * This prop is ignored on inputs with type other then text, search, url, tel and password. See https://html.spec.whatwg.org/multipage/input.html#do-not-apply for details. * @default false */ selectAllOnFocus?: boolean; /** * The type of input that should be shown, when not `multiline`. */ type?: string; /** Text value of controlled input. */ value?: string; /** Callback invoked when user cancels input with the `esc` key. Receives last confirmed value. */ onCancel?(value: string): void; /** Callback invoked when user changes input in any way. */ onChange?(value: string): void; /** Callback invoked when user confirms value with `enter` key or by blurring input. */ onConfirm?(value: string): void; /** Callback invoked after the user enters edit mode. */ onEdit?(value: string): void; } export interface IEditableTextState { /** Pixel height of the input, measured from span size */ inputHeight?: number; /** Pixel width of the input, measured from span size */ inputWidth?: number; /** Whether the value is currently being edited */ isEditing?: boolean; /** The last confirmed value */ lastValue?: string; /** The controlled input value, may be different from prop during editing */ value?: string; } const BUFFER_WIDTH_EDGE = 5; const BUFFER_WIDTH_IE = 30; @polyfill export class EditableText extends AbstractPureComponent2 { public static displayName = `${DISPLAYNAME_PREFIX}.EditableText`; public static defaultProps: IEditableTextProps = { alwaysRenderInput: false, confirmOnEnterKey: false, defaultValue: "", disabled: false, maxLines: Infinity, minLines: 1, minWidth: 80, multiline: false, placeholder: "Click to Edit", type: "text", }; private inputElement?: HTMLInputElement | HTMLTextAreaElement; private valueElement: HTMLSpanElement; private refHandlers = { content: (spanElement: HTMLSpanElement) => { this.valueElement = spanElement; }, input: (input: HTMLInputElement | HTMLTextAreaElement) => { if (input != null) { this.inputElement = input; // temporary fix for #3882 if (!this.props.alwaysRenderInput) { this.inputElement.focus(); } if (this.state != null && this.state.isEditing) { const supportsSelection = inputSupportsSelection(input); if (supportsSelection) { const { length } = input.value; input.setSelectionRange(this.props.selectAllOnFocus ? 0 : length, length); } if (!supportsSelection || !this.props.selectAllOnFocus) { input.scrollLeft = input.scrollWidth; } } } }, }; public constructor(props?: IEditableTextProps, context?: any) { super(props, context); const value = props.value == null ? props.defaultValue : props.value; this.state = { inputHeight: 0, inputWidth: 0, isEditing: props.isEditing === true && props.disabled === false, lastValue: value, value, }; } public render() { const { alwaysRenderInput, disabled, multiline } = this.props; const value = this.props.value == null ? this.state.value : this.props.value; const hasValue = value != null && value !== ""; const classes = classNames( Classes.EDITABLE_TEXT, Classes.intentClass(this.props.intent), { [Classes.DISABLED]: disabled, [Classes.EDITABLE_TEXT_EDITING]: this.state.isEditing, [Classes.EDITABLE_TEXT_PLACEHOLDER]: !hasValue, [Classes.MULTILINE]: multiline, }, this.props.className, ); let contentStyle: React.CSSProperties; if (multiline) { // set height only in multiline mode when not editing // otherwise we're measuring this element to determine appropriate height of text contentStyle = { height: !this.state.isEditing ? this.state.inputHeight : null }; } else { // minWidth only applies in single line mode (multiline == width 100%) contentStyle = { height: this.state.inputHeight, lineHeight: this.state.inputHeight != null ? `${this.state.inputHeight}px` : null, minWidth: this.props.minWidth, }; } // If we are always rendering an input, then NEVER make the container div focusable. // Otherwise, make container div focusable when not editing, so it can still be tabbed // to focus (when the input is rendered, it is itself focusable so container div doesn't need to be) const tabIndex = alwaysRenderInput || this.state.isEditing || disabled ? null : 0; // we need the contents to be rendered while editing so that we can measure their height // and size the container element responsively const shouldHideContents = alwaysRenderInput && !this.state.isEditing; return (
{alwaysRenderInput || this.state.isEditing ? this.renderInput(value) : undefined} {shouldHideContents ? ( undefined ) : ( {hasValue ? value : this.props.placeholder} )}
); } public componentDidMount() { this.updateInputDimensions(); } public componentDidUpdate(prevProps: IEditableTextProps, prevState: IEditableTextState) { const state: IEditableTextState = {}; if (this.props.value != null && this.props.value !== prevProps.value) { state.value = this.props.value; } if (this.props.isEditing != null && this.props.isEditing !== prevProps.isEditing) { state.isEditing = this.props.isEditing; } if (this.props.disabled || (this.props.disabled == null && prevProps.disabled)) { state.isEditing = false; } this.setState(state); if (this.state.isEditing && !prevState.isEditing) { safeInvoke(this.props.onEdit, this.state.value); } this.updateInputDimensions(); } public cancelEditing = () => { const { lastValue, value } = this.state; this.setState({ isEditing: false, value: lastValue }); if (value !== lastValue) { safeInvoke(this.props.onChange, lastValue); } safeInvoke(this.props.onCancel, lastValue); }; public toggleEditing = () => { if (this.state.isEditing) { const { value } = this.state; this.setState({ isEditing: false, lastValue: value }); safeInvoke(this.props.onConfirm, value); } else if (!this.props.disabled) { this.setState({ isEditing: true }); } }; private handleFocus = () => { const { alwaysRenderInput, disabled, selectAllOnFocus } = this.props; if (!disabled) { this.setState({ isEditing: true }); } if (alwaysRenderInput && selectAllOnFocus && this.inputElement != null) { const { length } = this.inputElement.value; this.inputElement.setSelectionRange(0, length); } }; private handleTextChange = (event: React.FormEvent) => { const value = (event.target as HTMLInputElement).value; // state value should be updated only when uncontrolled if (this.props.value == null) { this.setState({ value }); } safeInvoke(this.props.onChange, value); }; private handleKeyEvent = (event: React.KeyboardEvent) => { const { altKey, ctrlKey, metaKey, shiftKey, which } = event; if (which === Keys.ESCAPE) { this.cancelEditing(); return; } const hasModifierKey = altKey || ctrlKey || metaKey || shiftKey; if (which === Keys.ENTER) { // prevent IE11 from full screening with alt + enter // shift + enter adds a newline by default if (altKey || shiftKey) { event.preventDefault(); } if (this.props.confirmOnEnterKey && this.props.multiline) { if (event.target != null && hasModifierKey) { insertAtCaret(event.target as HTMLTextAreaElement, "\n"); this.handleTextChange(event); } else { this.toggleEditing(); } } else if (!this.props.multiline || hasModifierKey) { this.toggleEditing(); } } }; private renderInput(value: string) { const { maxLength, multiline, type, placeholder } = this.props; const props: React.InputHTMLAttributes = { className: Classes.EDITABLE_TEXT_INPUT, maxLength, onBlur: this.toggleEditing, onChange: this.handleTextChange, onKeyDown: this.handleKeyEvent, placeholder, value, }; const { inputHeight, inputWidth } = this.state; if (inputHeight !== 0 && inputWidth !== 0) { props.style = { height: inputHeight, lineHeight: !multiline && inputHeight != null ? `${inputHeight}px` : null, width: multiline ? "100%" : inputWidth, }; } return multiline ? (