diff --git a/build/config/eslint.json b/build/config/eslint.json
index d7888d971..7a9076b2c 100644
--- a/build/config/eslint.json
+++ b/build/config/eslint.json
@@ -12,6 +12,7 @@
"react/prop-types": [2, {"ignore": ["children"]}],
"eqeqeq": [2, "smart"],
"id-length": [0],
- "no-loop-func": [0]
+ "no-loop-func": [0],
+ "new-cap": [2, {"capIsNew": false}]
}
}
diff --git a/spec/components/editable-list-spec.jsx b/spec/components/editable-list-spec.jsx
new file mode 100644
index 000000000..1c7490923
--- /dev/null
+++ b/spec/components/editable-list-spec.jsx
@@ -0,0 +1,136 @@
+import React, {addons} from 'react/addons';
+import EditableList from '../../src/components/editable-list';
+
+const {findDOMNode} = React;
+const {TestUtils: {
+ renderIntoDocument,
+ findRenderedDOMComponentWithTag,
+ findRenderedDOMComponentWithClass,
+ scryRenderedDOMComponentsWithClass,
+ Simulate,
+}} = addons;
+const makeList = (items = [], props = {})=> {
+ return renderIntoDocument({items});
+};
+
+describe('EditableList', ()=> {
+ describe('_onItemClick', ()=> {
+ it('calls onItemSelected', ()=> {
+ const onItemSelected = jasmine.createSpy('onItemSelected');
+ const list = makeList(['1', '2'], {onItemSelected});
+ const item = scryRenderedDOMComponentsWithClass(list, 'editable-item')[0];
+
+ Simulate.click(item);
+
+ expect(onItemSelected).toHaveBeenCalledWith('1', 0);
+ });
+ });
+
+ describe('_onListKeyDown', ()=> {
+ it('calls onItemSelected', ()=> {
+ const onItemSelected = jasmine.createSpy('onItemSelected');
+ const list = makeList(['1', '2'], {initialState: {selected: 0}, onItemSelected});
+ const innerList = findRenderedDOMComponentWithClass(list, 'items-wrapper');
+
+ Simulate.keyDown(innerList, {key: 'ArrowDown'});
+
+ expect(onItemSelected).toHaveBeenCalledWith('2', 1);
+ });
+ });
+
+ describe('_renderItem', ()=> {
+ const makeItem = (item, idx, state = {}, handlers = {})=> {
+ const list = makeList();
+ return renderIntoDocument(
+ list._renderItem(item, idx, state, handlers)
+ );
+ };
+
+ it('binds correct click callbacks', ()=> {
+ const onClick = jasmine.createSpy('onClick');
+ const onDoubleClick = jasmine.createSpy('onDoubleClick');
+ const item = makeItem('item 1', 0, {}, {onClick, onDoubleClick});
+
+ Simulate.click(item);
+ expect(onClick.calls[0].args[1]).toEqual('item 1');
+ expect(onClick.calls[0].args[2]).toEqual(0);
+
+ Simulate.doubleClick(item);
+ expect(onDoubleClick.calls[0].args[1]).toEqual('item 1');
+ expect(onDoubleClick.calls[0].args[2]).toEqual(0);
+ });
+
+ it('renders correctly when item is selected', ()=> {
+ const item = findDOMNode(makeItem('item 1', 0, {selected: 0}));
+ expect(item.className.indexOf('selected')).not.toEqual(-1);
+ });
+
+ it('renders correctly when item is string', ()=> {
+ const item = findDOMNode(makeItem('item 1', 0));
+ expect(item.className.indexOf('selected')).toEqual(-1);
+ expect(item.className.indexOf('editable-item')).not.toEqual(-1);
+ expect(item.className.indexOf('component-item')).toEqual(-1);
+ expect(item.childNodes[0].textContent).toEqual('item 1');
+ });
+
+ it('renders correctly when item is component', ()=> {
+ const item = findDOMNode(makeItem(
, 0));
+ expect(item.className.indexOf('selected')).toEqual(-1);
+ expect(item.className.indexOf('editable-item')).toEqual(-1);
+ expect(item.className.indexOf('component-item')).not.toEqual(-1);
+ expect(item.childNodes[0].tagName).toEqual('DIV');
+ });
+
+ it('renders correctly when item is in editing state', ()=> {
+ const onInputBlur = jasmine.createSpy('onInputBlur');
+ const onInputFocus = jasmine.createSpy('onInputFocus');
+ const onInputKeyDown = jasmine.createSpy('onInputKeyDown');
+
+ const item = makeItem('item 1', 0, {editing: 0}, {onInputBlur, onInputFocus, onInputKeyDown});
+ const input = findRenderedDOMComponentWithTag(item, 'input');
+
+ Simulate.focus(input);
+ Simulate.keyDown(input);
+ Simulate.blur(input);
+
+ expect(onInputFocus).toHaveBeenCalled();
+ expect(onInputBlur).toHaveBeenCalled();
+ expect(onInputKeyDown.calls[0].args[1]).toEqual('item 1');
+ expect(onInputKeyDown.calls[0].args[2]).toEqual(0);
+
+ expect(findDOMNode(input).tagName).toEqual('INPUT');
+ });
+ });
+
+ describe('render', ()=> {
+ it('renders list of items', ()=> {
+ const items = ['1', '2', '3'];
+ const list = makeList(items);
+ const innerList = findDOMNode(
+ findRenderedDOMComponentWithClass(list, 'items-wrapper')
+ );
+ expect(innerList.childNodes.length).toEqual(3);
+ items.forEach((item, idx)=> expect(innerList.childNodes[idx].textContent).toEqual(item));
+ });
+
+ it('renders add button', ()=> {
+ const onCreateItem = jasmine.createSpy('onCreateItem');
+ const list = makeList([], {onCreateItem});
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[0];
+
+ Simulate.click(button);
+
+ expect(onCreateItem).toHaveBeenCalled();
+ });
+
+ it('renders delete button', ()=> {
+ const onDeleteItem = jasmine.createSpy('onDeleteItem');
+ const list = makeList(['1', '2'], {initialState: {selected: 1}, onDeleteItem});
+ const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
+
+ Simulate.click(button);
+
+ expect(onDeleteItem).toHaveBeenCalledWith('2', 1);
+ });
+ });
+});
diff --git a/src/components/editable-list.jsx b/src/components/editable-list.jsx
new file mode 100644
index 000000000..86e1f417d
--- /dev/null
+++ b/src/components/editable-list.jsx
@@ -0,0 +1,160 @@
+import _ from 'underscore';
+import classNames from 'classNames';
+import React, {Component, PropTypes} from 'react';
+
+class EditableList extends Component {
+ static displayName = 'EditableList'
+
+ static propTypes = {
+ children: PropTypes.arrayOf(PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ PropTypes.element,
+ ])),
+ className: PropTypes.string,
+ onCreateItem: PropTypes.func,
+ onDeleteItem: PropTypes.func,
+ onItemEdited: PropTypes.func,
+ onItemSelected: PropTypes.func,
+ initialState: PropTypes.object,
+ }
+
+ static defaultProps = {
+ children: [],
+ onCreateItem: ()=> {},
+ onDeleteItem: ()=> {},
+ onItemEdited: ()=> {},
+ onItemSelected: ()=> {},
+ }
+
+ constructor(props) {
+ super(props);
+ this._items = this.props.children;
+ this._doubleClickedItem = false;
+ this.state = props.initialState || {
+ editing: null,
+ selected: null,
+ };
+ }
+
+ _onInputBlur = ()=> {
+ this.setState({editing: null});
+ }
+
+ _onInputFocus = ()=> {
+ this._doubleClickedItem = false;
+ }
+
+ _onInputKeyDown = (event, item, idx)=> {
+ if (_.includes(['Enter', 'Return'], event.key)) {
+ this.setState({editing: null});
+ this.props.onItemEdited(event.target.value, item, idx);
+ } else if (event.key === 'Escape') {
+ this.setState({editing: null});
+ }
+ }
+
+ _onItemClick = (event, item, idx)=> {
+ this._selectItem(item, idx);
+ }
+
+ _onItemDoubleClick = (event, item, idx)=> {
+ if (!React.isValidElement(item)) {
+ this._doubleClickedItem = true;
+ this.setState({editing: idx});
+ }
+ }
+
+ _onListBlur = ()=> {
+ if (!this._doubleClickedItem) {
+ this.setState({selected: null});
+ }
+ }
+
+ _onListKeyDown = (event)=> {
+ const len = this._items.size;
+ const handle = {
+ 'ArrowUp': (sel)=> sel === 0 ? sel : sel - 1,
+ 'ArrowDown': (sel)=> sel === len - 1 ? sel : sel + 1,
+ 'Escape': ()=> null,
+ };
+ const selected = (handle[event.key] || ((sel)=> sel))(this.state.selected);
+ this._selectItem(this._items[selected], selected);
+ }
+
+ _onCreateItem = ()=> {
+ this.props.onCreateItem();
+ }
+
+ _onDeleteItem = ()=> {
+ const idx = this.state.selected;
+ const item = this._items[idx];
+ if (item) {
+ this.props.onDeleteItem(item, idx);
+ }
+ }
+
+ _selectItem = (item, idx)=> {
+ this.setState({selected: idx});
+ this.props.onItemSelected(item, idx);
+ }
+
+ _renderItem = (item, idx, {editing, selected} = this.state, handlers = {})=> {
+ const onClick = handlers.onClick || this._onItemClick;
+ const onDoubleClick = handlers.onDoubleClick || this._onItemDoubleClick;
+ const onInputBlur = handlers.onInputBlur || this._onInputBlur;
+ const onInputFocus = handlers.onInputFocus || this._onInputFocus;
+ const onInputKeyDown = handlers.onInputKeyDown || this._onInputKeyDown;
+
+ const classes = classNames({
+ 'component-item': React.isValidElement(item),
+ 'editable-item': !React.isValidElement(item),
+ 'selected': selected === idx,
+ });
+ let itemToRender = item;
+ if (React.isValidElement(item)) {
+ itemToRender = item;
+ } else if (editing === idx) {
+ itemToRender = (
+
+ );
+ }
+
+ return (
+
+ {itemToRender}
+
+ );
+ }
+
+ render() {
+ return (
+
+
+ {this._items.map((item, idx)=> this._renderItem(item, idx))}
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default EditableList;
diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee
index f3ef5404b..3422407eb 100644
--- a/src/global/nylas-component-kit.coffee
+++ b/src/global/nylas-component-kit.coffee
@@ -31,6 +31,7 @@ class NylasComponentKit
@load "TimeoutTransitionGroup", 'timeout-transition-group'
@load "ConfigPropContainer", "config-prop-container"
@load "DisclosureTriangle", "disclosure-triangle"
+ @load "EditableList", "editable-list"
@load "ScrollRegion", 'scroll-region'
@load "ResizableRegion", 'resizable-region'
diff --git a/static/components/editable-list.less b/static/components/editable-list.less
new file mode 100644
index 000000000..0d83d0225
--- /dev/null
+++ b/static/components/editable-list.less
@@ -0,0 +1,50 @@
+@import "ui-variables";
+
+.nylas-editable-list {
+
+ .items-wrapper {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid @border-secondary-bg;
+ background-color: @background-secondary;
+ font-size: 0.9em;
+
+ .editable-item {
+ padding: @padding-small-vertical @padding-small-horizontal;
+ cursor: default;
+
+ &.selected {
+ background-color: @background-selected;
+ color: @text-color-selected;
+ }
+ &+.editable-item {
+ border-top: 1px solid @border-color-divider;
+ }
+ input {
+ border: none;
+ padding: 0;
+ font-size: inherit;
+ background-color: @background-selected;
+ color: @text-color-selected;
+ }
+ ::-webkit-input-placeholder {
+ color: @text-color-inverse-very-subtle;
+ }
+ }
+ }
+
+ .buttons-wrapper {
+ margin-top: @padding-xs-horizontal;
+
+ .btn-editable-list {
+ height: 20px;
+ width: 30px;
+ text-align: center;
+ padding: 0 0 2px 0;
+
+ &+.btn-editable-list {
+ margin-left: 3px;
+ }
+ }
+ }
+}
diff --git a/static/index.less b/static/index.less
index d8cdf6b38..ab4365f2e 100644
--- a/static/index.less
+++ b/static/index.less
@@ -28,3 +28,4 @@
@import "components/unsafe";
@import "components/key-commands-region";
@import "components/contenteditable";
+@import "components/editable-list";