Created
November 24, 2016 17:58
-
-
Save jaredpalmer/5ef34b0f333840bd5f5124d01a5d9fcf to your computer and use it in GitHub Desktop.
EditableText.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.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; } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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