Skip to content

Instantly share code, notes, and snippets.

@jaredpalmer
Created November 24, 2016 17:58
Show Gist options
  • Save jaredpalmer/5ef34b0f333840bd5f5124d01a5d9fcf to your computer and use it in GitHub Desktop.
Save jaredpalmer/5ef34b0f333840bd5f5124d01a5d9fcf to your computer and use it in GitHub Desktop.
EditableText.js
/**
* This is converted from @palantir/blueprint's editable text.
*
* @see http://blueprintjs.com/docs/#components.editable
*/
import React, {Component} from 'react'
import classNames from 'classnames'
import { clamp, safeInvoke } from '../utils'
import PureRender from "pure-render-decorator";
const Intent = {
NONE: -1,
PRIMARY: 0,
SUCCESS: 1,
WARNING: 2,
DANGER: 3
}
const Classes = {
DISABLED: "pt-disabled",
EDITABLE_TEXT: "pt-editable-text",
intentClass(intent = Intent.NONE) {
if (intent === Intent.NONE || Intent[intent] == null) {
return undefined;
}
return `pt-intent-${Intent[intent].toLowerCase()}`;
}
}
const Keys = {
ARROW_DOWN: 40,
ARROW_LEFT: 37,
ARROW_RIGHT: 39,
ARROW_UP: 38,
ENTER: 13,
ESCAPE: 27,
SPACE: 32
}
const BUFFER_WIDTH = 30;
class EditableText extends Component {
constructor (props) {
super(props)
this.state = {
inputHeight: 0,
inputWidth: 0,
isEditing: props.isEditing === true && props.disabled === false
}
}
valueElement = {}
refHandlers = {
content: (spanElement) => {
this.valueElement = spanElement;
},
input: (input) => {
if (input != null) {
input.focus();
const { length } = input.value;
input.setSelectionRange(this.props.selectAllOnFocus ? 0 : length, length);
}
},
};
render () {
const { 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,
"pt-editable-editing": this.state.isEditing,
"pt-editable-placeholder": !hasValue,
"pt-multiline": multiline,
},
this.props.className,
);
let contentStyle;
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,
};
}
// make enclosing div focusable when not editing, so it can still be tabbed to focus
// (when editing, input itself is focusable so div doesn't need to be)
const tabIndex = this.state.isEditing || disabled ? null : 0;
return (
<div className={classes} onFocus={this.handleFocus} tabIndex={tabIndex}>
{this.maybeRenderInput(value)}
<span className="pt-editable-content" ref={this.refHandlers.content} style={contentStyle}>
{hasValue ? value : this.props.placeholder}
</span>
</div>
);
}
componentDidMount() {
this.updateInputDimensions();
}
componentDidUpdate(_, prevState) {
if (this.state.isEditing && !prevState.isEditing) {
safeInvoke(this.props.onEdit);
}
this.updateInputDimensions();
}
componentWillReceiveProps(nextProps) {
const state = { value: getValue(nextProps) };
if (nextProps.isEditing != null) {
state.isEditing = nextProps.isEditing;
}
if (nextProps.disabled || (nextProps.disabled == null && this.props.disabled)) {
state.isEditing = false;
}
this.setState(state);
}
cancelEditing = () => {
const { lastValue } = this.state;
this.setState({ isEditing: false, value: lastValue });
// invoke onCancel after onChange so consumers' onCancel can override their onChange
safeInvoke(this.props.onChange, lastValue);
safeInvoke(this.props.onCancel, lastValue);
}
toggleEditing = () => {
if (this.state.isEditing) {
const { value } = this.state;
this.setState({
isEditing: false,
lastValue: value,
});
safeInvoke(this.props.onChange, value);
safeInvoke(this.props.onConfirm, value);
} else if (!this.props.disabled) {
this.setState({ isEditing: true });
}
}
handleFocus = () => {
if (!this.props.disabled) {
this.setState({ isEditing: true });
}
}
handleTextChange = (event) => {
const value = (event.target).value;
// state value should be updated only when uncontrolled
if (this.props.value == null) { this.setState({ value }); }
safeInvoke(this.props.onChange, value);
}
handleKeyEvent = ({ ctrlKey, metaKey, which }) => {
if (which === Keys.ENTER && (!this.props.multiline || ctrlKey || metaKey)) {
this.toggleEditing();
} else if (which === Keys.ESCAPE) {
this.cancelEditing();
}
}
maybeRenderInput(value) {
const { multiline } = this.props;
if (!this.state.isEditing) {
return undefined;
}
const props = {
className: "pt-editable-input",
onBlur: this.toggleEditing,
onChange: this.handleTextChange,
onKeyDown: this.handleKeyEvent,
ref: this.refHandlers.input,
style: {
height: this.state.inputHeight,
lineHeight: !multiline && this.state.inputHeight != null ? `${this.state.inputHeight}px` : null,
width: multiline ? "100%" : this.state.inputWidth,
},
value,
};
return multiline ? <textarea {...props} /> : <input type="text" {...props} />;
}
updateInputDimensions() {
if (this.valueElement != null) {
const { maxLines, minLines, minWidth, multiline } = this.props;
let { parentElement, scrollHeight, scrollWidth, textContent } = this.valueElement;
const lineHeight = getLineHeight(this.valueElement);
// add one line to computed <span> height if text ends in newline
// because <span> collapses that trailing whitespace but <textarea> shows it
if (multiline && this.state.isEditing && /\n$/.test(textContent)) {
scrollHeight += lineHeight;
}
if (lineHeight > 0) {
// line height could be 0 if the isNaN block from getLineHeight kicks in
scrollHeight = clamp(scrollHeight, minLines * lineHeight, maxLines * lineHeight);
}
// Chrome's input caret height misaligns text so the line-height must be larger than font-size.
// The computed scrollHeight must also account for a larger inherited line-height from the parent.
scrollHeight = Math.max(scrollHeight, getFontSize(this.valueElement) + 1, getLineHeight(parentElement));
// IE11 needs a small buffer so text does not shift prior to resizing
this.setState({
inputHeight: scrollHeight,
inputWidth: Math.max(scrollWidth + BUFFER_WIDTH, minWidth),
});
// synchronizes the ::before pseudo-element's height while editing for Chrome 53
if (multiline && this.state.isEditing) {
setTimeout(() => parentElement.style.height = `${scrollHeight}px`);
}
}
}
}
function getValue(props) {
return props.value == null ? props.defaultValue : props.value;
}
function getFontSize(element) {
const fontSize = getComputedStyle(element).fontSize;
return fontSize === "" ? 0 : parseInt(fontSize.slice(0, -2), 10);
}
function getLineHeight(element) {
// getComputedStyle() => 18.0001px => 18
let lineHeight = parseInt(getComputedStyle(element).lineHeight.slice(0, -2), 10);
// this check will be true if line-height is a keyword like "normal"
if (isNaN(lineHeight)) {
// @see http://stackoverflow.com/a/18430767/6342931
const line = document.createElement("span");
line.innerHTML = "<br>";
element.appendChild(line);
const singleLineHeight = element.offsetHeight;
line.innerHTML = "<br><br>";
const doubleLineHeight = element.offsetHeight;
element.removeChild(line);
// this can return 0 in edge cases
lineHeight = doubleLineHeight - singleLineHeight;
}
return lineHeight;
}
export default React.createFactory(PureRender(EditableText))
import React, {Component} from 'react'
import EditableText from './components/EditableText'
import { style as css } from 'glamor'
import { buttonStyle, theme } from './components/jsxstyle'
import cx from 'classnames'
const style = {
wrapper: css({
margin: '1rem'
})
}
class Proto extends Component {
state = {
report: "",
selectAllOnFocus: false,
title: "",
}
handleReportChange = (report) => this.setState({ report })
handleDone = (e) => this.setState({ isEditing: false})
handleEdit = (e) => this.setState({ isEditing: true})
render () {
return (
<div className={style.wrapper}>
<div style={{ padding: '0 14px', height: '40px' }}>
<div style={{
backgroundColor: theme.purple,
color: 'white',
textTransform: 'uppercase',
letterSpacing: '.025em',
height: '100%',
fontSize: '15px',
fontWeight: 'bold',
borderRadius: '4px',
borderStyle: 'none',
padding: '0 14px',
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: 140,
boxShadow: '0 4px 6px rgba(50,50,93,.11),0 1px 3px rgba(0,0,0,.08)',
}} className="pt-dark">
<EditableText
placeholder="Edit me"
selectAllOnFocus={false}
value={this.state.report}
onEdit={this.handleEdit}
onCancel={this.handleDone}
onConfirm={this.handleDone}
onChange={this.handleReportChange}
/>
</div>
</div>
</div>
)
}
}
export default Proto
.pt-editable-text {
display: inline-block;
position: relative;
cursor: text;
max-width: 100%;
vertical-align: top;
white-space: nowrap; }
.pt-editable-text::before {
position: absolute;
top: -3px;
right: -3px;
bottom: -3px;
left: -3px;
border-radius: 3px;
content: "";
transition: background-color 100ms cubic-bezier(0.4, 1, 0.75, 0.9), box-shadow 100ms cubic-bezier(0.4, 1, 0.75, 0.9); }
.pt-editable-text:hover::before {
box-shadow: 0 0 0 0 rgba(19, 124, 189, 0), 0 0 0 0 rgba(19, 124, 189, 0), inset 0 0 0 1px rgba(16, 22, 26, 0.15); }
.pt-editable-text.pt-editable-editing::before {
box-shadow: 0 0 0 1px #137cbd, 0 0 0 3px rgba(19, 124, 189, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.2);
background-color: #ffffff; }
.pt-editable-text.pt-disabled::before {
box-shadow: none; }
.pt-editable-text.pt-intent-primary .pt-editable-input,
.pt-editable-text.pt-intent-primary .pt-editable-content {
color: #137cbd; }
.pt-editable-text.pt-intent-primary:hover::before {
box-shadow: 0 0 0 0 rgba(19, 124, 189, 0), 0 0 0 0 rgba(19, 124, 189, 0), inset 0 0 0 1px rgba(19, 124, 189, 0.4); }
.pt-editable-text.pt-intent-primary.pt-editable-editing::before {
box-shadow: 0 0 0 1px #137cbd, 0 0 0 3px rgba(19, 124, 189, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.2); }
.pt-editable-text.pt-intent-success .pt-editable-input,
.pt-editable-text.pt-intent-success .pt-editable-content {
color: #0f9960; }
.pt-editable-text.pt-intent-success:hover::before {
box-shadow: 0 0 0 0 rgba(15, 153, 96, 0), 0 0 0 0 rgba(15, 153, 96, 0), inset 0 0 0 1px rgba(15, 153, 96, 0.4); }
.pt-editable-text.pt-intent-success.pt-editable-editing::before {
box-shadow: 0 0 0 1px #0f9960, 0 0 0 3px rgba(15, 153, 96, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.2); }
.pt-editable-text.pt-intent-warning .pt-editable-input,
.pt-editable-text.pt-intent-warning .pt-editable-content {
color: #d9822b; }
.pt-editable-text.pt-intent-warning:hover::before {
box-shadow: 0 0 0 0 rgba(217, 130, 43, 0), 0 0 0 0 rgba(217, 130, 43, 0), inset 0 0 0 1px rgba(217, 130, 43, 0.4); }
.pt-editable-text.pt-intent-warning.pt-editable-editing::before {
box-shadow: 0 0 0 1px #d9822b, 0 0 0 3px rgba(217, 130, 43, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.2); }
.pt-editable-text.pt-intent-danger .pt-editable-input,
.pt-editable-text.pt-intent-danger .pt-editable-content {
color: #db3737; }
.pt-editable-text.pt-intent-danger:hover::before {
box-shadow: 0 0 0 0 rgba(219, 55, 55, 0), 0 0 0 0 rgba(219, 55, 55, 0), inset 0 0 0 1px rgba(219, 55, 55, 0.4); }
.pt-editable-text.pt-intent-danger.pt-editable-editing::before {
box-shadow: 0 0 0 1px #db3737, 0 0 0 3px rgba(219, 55, 55, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.2); }
.pt-dark .pt-editable-text:hover::before {
box-shadow: 0 0 0 0 rgba(19, 124, 189, 0), 0 0 0 0 rgba(19, 124, 189, 0), inset 0 0 0 1px rgba(255, 255, 255, 0.15); }
.pt-dark .pt-editable-text.pt-editable-editing::before {
box-shadow: 0 0 0 1px #137cbd, 0 0 0 3px rgba(19, 124, 189, 0.3), inset 0 0 0 1px rgba(16, 22, 26, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.4);
/*box-shadow: 0 0 0 1px rgba(191, 204, 214, 0.5), */
background-color: rgba(16, 22, 26, 0.3);
}
.pt-dark .pt-editable-text.pt-disabled::before {
box-shadow: none; }
.pt-dark .pt-editable-text.pt-intent-primary .pt-editable-content {
color: #2b95d6; }
.pt-dark .pt-editable-text.pt-intent-primary:hover::before {
box-shadow: 0 0 0 0 rgba(43, 149, 214, 0), 0 0 0 0 rgba(43, 149, 214, 0), inset 0 0 0 1px rgba(43, 149, 214, 0.4); }
.pt-dark .pt-editable-text.pt-intent-primary.pt-editable-editing::before {
box-shadow: 0 0 0 1px #2b95d6, 0 0 0 3px rgba(43, 149, 214, 0.3), inset 0 0 0 1px rgba(16, 22, 26, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.4); }
.pt-dark .pt-editable-text.pt-intent-success .pt-editable-content {
color: #15b371; }
.pt-dark .pt-editable-text.pt-intent-success:hover::before {
box-shadow: 0 0 0 0 rgba(21, 179, 113, 0), 0 0 0 0 rgba(21, 179, 113, 0), inset 0 0 0 1px rgba(21, 179, 113, 0.4); }
.pt-dark .pt-editable-text.pt-intent-success.pt-editable-editing::before {
box-shadow: 0 0 0 1px #15b371, 0 0 0 3px rgba(21, 179, 113, 0.3), inset 0 0 0 1px rgba(16, 22, 26, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.4); }
.pt-dark .pt-editable-text.pt-intent-warning .pt-editable-content {
color: #f29d49; }
.pt-dark .pt-editable-text.pt-intent-warning:hover::before {
box-shadow: 0 0 0 0 rgba(242, 157, 73, 0), 0 0 0 0 rgba(242, 157, 73, 0), inset 0 0 0 1px rgba(242, 157, 73, 0.4); }
.pt-dark .pt-editable-text.pt-intent-warning.pt-editable-editing::before {
box-shadow: 0 0 0 1px #f29d49, 0 0 0 3px rgba(242, 157, 73, 0.3), inset 0 0 0 1px rgba(16, 22, 26, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.4); }
.pt-dark .pt-editable-text.pt-intent-danger .pt-editable-content {
color: #f55656; }
.pt-dark .pt-editable-text.pt-intent-danger:hover::before {
box-shadow: 0 0 0 0 rgba(245, 86, 86, 0), 0 0 0 0 rgba(245, 86, 86, 0), inset 0 0 0 1px rgba(245, 86, 86, 0.4); }
.pt-dark .pt-editable-text.pt-intent-danger.pt-editable-editing::before {
box-shadow: 0 0 0 1px #f55656, 0 0 0 3px rgba(245, 86, 86, 0.3), inset 0 0 0 1px rgba(16, 22, 26, 0.3), inset 0 1px 1px rgba(16, 22, 26, 0.4); }
.pt-editable-input,
.pt-editable-content {
display: inherit;
position: relative;
min-width: inherit;
max-width: inherit;
vertical-align: top;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
font: inherit;
resize: none; }
.pt-editable-input {
border: none;
box-shadow: none;
background: none;
width: 100%;
padding: 0;
white-space: pre-wrap; }
.pt-editable-input:focus {
outline: none; }
.pt-editable-input::-ms-clear {
display: none; }
.pt-editable-content {
overflow: hidden;
padding-right: 2px;
text-overflow: ellipsis;
white-space: pre; }
.pt-editable-editing > .pt-editable-content {
position: absolute;
left: 0;
visibility: hidden; }
.pt-editable-placeholder > .pt-editable-content {
color: rgba(92, 112, 128, 0.5); }
.pt-dark .pt-editable-placeholder > .pt-editable-content {
color: rgba(191, 204, 214, 0.5); }
.pt-editable-text.pt-multiline {
display: block; }
.pt-editable-text.pt-multiline .pt-editable-content {
overflow: auto;
white-space: pre-wrap; }
// Clamps the given number between min and max values.
// Returns value if within range, or closest bound.
export function clamp (val, min, max) {
if (max < min) {
throw new Error("clamp: max cannot be less than min");
}
return Math.min(Math.max(val, min), max);
}
// Returns whether the value is a function. Acts as a type guard.
export function isFunction (value) {
return typeof value === "function";
}
// Safely invoke the function with the given arguments, if it is indeed a function, and return its value.
export function safeInvoke(func, ...args) {
if (isFunction(func)) {
return func(...args);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment