Mailspring/src/components/editable-list.jsx
Ben Gotow 39768fd9d4 bump(react): 0.13.2 => 0.14.7
Great breakdown of React changes here:
https://github.com/facebook/react/blob/master/CHANGELOG.md#0140-october-7-2015

Due to deprecation warnings, I don't think this will break third-party extensions unless they were doing really bad things.
2016-03-29 01:43:12 -07:00

479 lines
14 KiB
JavaScript

import _ from 'underscore';
import classNames from 'classnames';
import ScrollRegion from './scroll-region';
import KeyCommandsRegion from './key-commands-region';
import RetinaImg from './retina-img';
import React, {Component, PropTypes} from 'react';
import ReactDOM from 'react-dom';
/**
* Renders a list of items and renders controls to add/edit/remove items.
* It resembles OS X's default list component.
* An item can be a React Component, a string or number.
*
* EditableList handles:
* - Keyboard and mouse interactions to select an item
* - Input to create a new item when the add button is clicked
* - Callback to remove item when the remove button is clicked
* - Double click to edit item, or use an edit button icon
*
* @param {object} props - props for EditableList
* @param {string} props.className - CSS class to be applied to component
* @param {array} props.items - Items to be rendered by the list
* @param {function} props.itemContent - A function that returns a component
* or string for each item. To be editable, itemContent must be a string.
* If no function is provided, each value in `items` is coerced to a string.
* @param {(string|object)} props.selected - The selected item. This prop is
* optional unless uou want to control the selection externally.
* @param {boolean} props.showEditIcon - Determines wether to show edit icon
* button on selected items
* @param {object} props.createInputProps - Props object to be passed on to
* the create input element. However, keep in mind that these props can not
* override the default props that EditableList will pass to the input.
* @param {props.onCreateItem} props.onCreateItem
* @param {props.onDeleteItem} props.onDeleteItem
* @param {props.onSelectItem} props.onSelectItem
* @param {props.onReorderItem} props.onReorderItem
* @param {props.onItemEdited} props.onItemEdited
* @param {props.onItemCreated} props.onItemCreated
* @class EditableList
*/
class EditableList extends Component {
static displayName = 'EditableList';
/**
* If provided, this function will be called when the add button is clicked,
* and will prevent an input to be added at the end of the list
* @callback props.onCreateItem
*/
/**
* If provided, this function will be called when the delete button is clicked.
* @callback props.onDeleteItem
* @param {(Component|string|number)} selectedItem - The selected item.
* @param {number} idx - The selected item idx
*/
/**
* If provided, this function will be called when an item has been edited. This only
* applies to items that are not React Components.
* @callback props.onItemEdited
* @param {string} newValue - The new value for the item
* @param {(string|number)} originalValue - The original value for the item
* @param {number} idx - The index of the edited item
*/
/**
* If provided, this function will be called when an item is selected via click or arrow
* keys. If the selection is cleared, it will receive null.
* @callback props.onSelectItem
* @param {(Component|string|number)} selectedItem - The selected item or null
* when selection cleared
* @param {number} idx - The index of the selected item or null when selection
* cleared
*/
/**
* If provided, this function will be called when the user has entered a value to create
* a new item in the new item input. This function will be called when the
* user presses Enter or when the input is blurred.
* @callback props.onItemCreated
* @param {string} value - The value for the new item
*/
/**
* If provided, the user will be able to drag and drop items to re-arrange them
* within the list. Note that dragging between lists is not supported.
* @callback props.onReorderItem
* @param {Object} item - The item that was dragged
* @param {number} startIdx - The index the item was dragged from
* @param {number} endIdx - The new index of the item, assuming it was
already removed from startIdx.
*/
static propTypes = {
items: PropTypes.array.isRequired,
itemContent: PropTypes.func,
className: PropTypes.string,
showEditIcon: PropTypes.bool,
createInputProps: PropTypes.object,
onCreateItem: PropTypes.func,
onDeleteItem: PropTypes.func,
onReorderItem: PropTypes.func,
onItemEdited: PropTypes.func,
onItemCreated: PropTypes.func,
/* Optional, if you choose to control selection externally */
selected: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
onSelectItem: PropTypes.func,
};
static defaultProps = {
items: [],
itemContent: (item)=> item,
className: '',
createInputProps: {},
showEditIcon: false,
onDeleteItem: ()=> {},
onItemEdited: ()=> {},
onItemCreated: ()=> {},
};
constructor(props) {
super(props);
this.state = {
dropInsertionIndex: -1,
editingIndex: -1,
creatingItem: false,
};
}
// Helpers
_createItem = (value)=> {
this._clearCreatingState(()=> {
if (value) {
this.props.onItemCreated(value);
}
});
};
_updateItem = (value, originalItem, idx)=> {
this._clearEditingState(()=> {
this.props.onItemEdited(value, originalItem, idx);
});
};
_getSelectedItem = ()=> {
if (this.props.onSelectItem) {
return this.props.selected;
}
return this.state.selected;
};
_selectItem = (item, idx)=> {
if (this.props.onSelectItem) {
this.props.onSelectItem(item, idx);
} else {
this.setState({selected: item});
}
};
_clearEditingState = (callback)=> {
this._setStateAndFocus({editingIndex: -1}, callback);
};
_clearCreatingState = (callback)=> {
this._setStateAndFocus({creatingItem: false}, callback);
};
_setStateAndFocus = (state, callback = ()=> {})=> {
this.setState(state, ()=> {
this._focusSelf();
callback();
});
};
_focusSelf = ()=> {
ReactDOM.findDOMNode(this).focus();
};
/**
* @private Scrolls to the dom node of the item at the provided index
* @param {number} idx - Index of item inside the list to scroll to
*/
_scrollTo = (idx)=> {
if (!idx) return;
const list = this.refs.itemsWrapper;
const nodes = ReactDOM.findDOMNode(list).querySelectorAll('.list-item');
list.scrollTo(nodes[idx]);
};
// Handlers
_onEditInputBlur = (event, item, idx)=> {
this._updateItem(event.target.value, item, idx);
};
_onEditInputFocus = (event)=> {
const input = event.target;
// Move cursor to the end of the input
input.selectionStart = input.selectionEnd = input.value.length;
};
_onEditInputKeyDown = (event, item, idx)=> {
event.stopPropagation();
if (_.includes(['Enter', 'Return'], event.key)) {
this._updateItem(event.target.value, item, idx);
} else if (event.key === 'Escape') {
this._clearEditingState();
}
};
_onCreateInputBlur = (event)=> {
this._createItem(event.target.value);
};
_onCreateInputKeyDown = (event)=> {
event.stopPropagation();
if (_.includes(['Enter', 'Return'], event.key)) {
this._createItem(event.target.value);
} else if (event.key === 'Escape') {
this._clearCreatingState();
}
};
_onItemClick = (event, item, idx)=> {
this._selectItem(item, idx);
};
_onItemEdit = (event, item, idx)=> {
this.setState({editingIndex: idx});
};
_listKeymapHandlers = ()=> {
const _shift = (dir) => {
const len = this.props.items.length;
const index = this.props.items.indexOf(this._getSelectedItem());
const newIndex = Math.min(len - 1, Math.max(0, index + dir));
if (index === newIndex) {
return;
}
this._scrollTo(newIndex);
this._selectItem(this.props.items[newIndex], newIndex);
};
return {
'core:previous-item': (event)=> {
event.stopPropagation();
_shift(-1);
},
'core:next-item': (event)=> {
event.stopPropagation();
_shift(1);
},
};
};
_onCreateItem = ()=> {
if (this.props.onCreateItem) {
this.props.onCreateItem();
} else {
this.setState({creatingItem: true});
}
};
_onDeleteItem = ()=> {
const selectedItem = this._getSelectedItem();
const index = this.props.items.indexOf(selectedItem);
if (selectedItem) {
// Move the selection 1 up after deleting
const len = this.props.items.length;
const newIndex = Math.min(len - 2, Math.max(0, index + 1));
this.props.onDeleteItem(selectedItem, index);
if (this.props.items[newIndex]) {
this._selectItem(this.props.items[newIndex], newIndex);
}
}
};
_onItemDragStart = (event)=> {
if (!this.props.onReorderItem) {
event.preventDefault();
return;
}
const row = event.target.closest('[data-item-idx]') || event.target;
const wrapperId = ReactDOM.findDOMNode(this.refs.itemsWrapper).dataset.reactid;
if (!row.dataset.itemIdx) {
return;
}
if (row.dataset.itemIdx / 1 === this.state.editingIndex / 1) {
// dragging the row currently being edited makes text selection impossible
event.preventDefault();
return;
}
event.dataTransfer.setData('editablelist-index', row.dataset.itemIdx);
event.dataTransfer.setData('editablelist-reactid', wrapperId);
event.dataTransfer.effectAllowed = "move";
};
_onDragOver = (event)=> {
const wrapperNode = ReactDOM.findDOMNode(this.refs.itemsWrapper);
const originWrapperId = event.dataTransfer.getData('editablelist-reactid')
const originSameList = (originWrapperId === wrapperNode.dataset.reactid);
let dropInsertionIndex = 0;
if ((event.currentTarget === wrapperNode) && originSameList) {
const itemNodes = wrapperNode.querySelectorAll('[data-item-idx]')
for (let i = 0; i < itemNodes.length; i ++) {
const itemNode = itemNodes[i];
const rect = itemNode.getBoundingClientRect();
if (event.clientY > rect.top + (rect.height / 2)) {
dropInsertionIndex = itemNode.dataset.itemIdx / 1 + 1;
} else {
break;
}
}
} else {
dropInsertionIndex = -1;
}
if (this.state.dropInsertionIndex !== dropInsertionIndex) {
this.setState({dropInsertionIndex: dropInsertionIndex});
}
};
_onDragLeave = ()=> {
this.setState({dropInsertionIndex: -1});
};
_onDrop = (event)=> {
if (this.state.dropInsertionIndex !== -1) {
const startIdx = event.dataTransfer.getData('editablelist-index');
if (startIdx && (this.state.dropInsertionIndex !== startIdx)) {
const item = this.props.items[startIdx];
let endIdx = this.state.dropInsertionIndex;
if (endIdx > startIdx) {
endIdx -= 1
}
this.props.onReorderItem(item, startIdx, endIdx);
this.setState({dropInsertionIndex: -1});
}
}
};
// Renderers
_renderEditInput = (item, itemContent, idx, handlers = {})=> {
const onInputBlur = handlers.onInputBlur || this._onEditInputBlur;
const onInputFocus = handlers.onInputFocus || this._onEditInputFocus;
const onInputKeyDown = handlers.onInputKeyDown || this._onEditInputKeyDown;
return (
<input
autoFocus
type="text"
placeholder={itemContent}
defaultValue={itemContent}
onBlur={_.partial(onInputBlur, _, item, idx)}
onFocus={onInputFocus}
onKeyDown={_.partial(onInputKeyDown, _, item, idx)} />
);
};
/**
* @private Will render the create input with the provided input props.
* Provided props will be overriden with the props that EditableList needs to
* pass to the input.
*/
_renderCreateInput = ()=> {
const props = _.extend(this.props.createInputProps, {
autoFocus: true,
type: 'text',
onBlur: this._onCreateInputBlur,
onKeyDown: this._onCreateInputKeyDown,
});
return (
<div className="create-item-input" key="create-item-input">
<input {...props}/>
</div>
);
};
// handlers object for testing
_renderItem = (item, idx, {editingIndex} = this.state, handlers = {})=> {
const onClick = handlers.onClick || this._onItemClick;
const onEdit = handlers.onEdit || this._onItemEdit;
let itemContent = this.props.itemContent(item);
const itemIsEditable = !React.isValidElement(itemContent);
const classes = classNames({
'list-item': true,
'selected': item === this._getSelectedItem(),
'editing': idx === editingIndex,
'editable-item': itemIsEditable,
'with-edit-icon': this.props.showEditIcon && editingIndex !== idx,
});
if ((editingIndex === idx) && itemIsEditable) {
itemContent = this._renderEditInput(item, itemContent, idx, handlers);
}
return (
<div
className={classes}
key={idx}
data-item-idx={idx}
draggable
onDragStart={this._onItemDragStart}
onClick={_.partial(onClick, _, item, idx)}
onDoubleClick={_.partial(onEdit, _, item, idx)}>
{itemContent}
<RetinaImg
className="edit-icon"
name="edit-icon.png"
title="Edit Item"
mode={RetinaImg.Mode.ContentIsMask}
onClick={_.partial(onEdit, _, item, idx)} />
</div>
);
};
_renderButtons = ()=> {
const deleteClasses = classNames({
'btn-editable-list': true,
'btn-disabled': !this._getSelectedItem(),
});
return (
<div className="buttons-wrapper">
<div className="btn-editable-list" onClick={this._onCreateItem}>
<span>+</span>
</div>
<div className={deleteClasses} onClick={this._onDeleteItem}>
<span></span>
</div>
</div>
);
};
_renderDropInsertion = ()=> {
return (
<div className="insertion-point"><div></div></div>
)
};
render() {
let items = this.props.items.map( (item, idx)=> this._renderItem(item, idx));
if (this.state.creatingItem === true) {
items = items.concat(this._renderCreateInput());
}
if (this.state.dropInsertionIndex !== -1) {
items.splice(this.state.dropInsertionIndex, 0, this._renderDropInsertion());
}
return (
<KeyCommandsRegion
tabIndex="1"
localHandlers={this._listKeymapHandlers()}
className={`nylas-editable-list ${this.props.className}`}>
<ScrollRegion
className="items-wrapper"
ref="itemsWrapper"
onDragOver={this._onDragOver}
onDragLeave={this._onDragLeave}
onDrop={this._onDrop}>
{items}
</ScrollRegion>
{this._renderButtons()}
</KeyCommandsRegion>
);
}
}
export default EditableList;