mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 13:14:16 +08:00
feat(components): Add EditableList component to component-kit
Summary: - Generic list component wich supports adding, editing and removing string-like items or components - Needs some css love Test Plan: - Unit tests. Reviewers: evan, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2322
This commit is contained in:
parent
ef7266d362
commit
07f81f81a6
6 changed files with 350 additions and 1 deletions
|
@ -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}]
|
||||
}
|
||||
}
|
||||
|
|
136
spec/components/editable-list-spec.jsx
Normal file
136
spec/components/editable-list-spec.jsx
Normal file
|
@ -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(<EditableList {...props}>{items}</EditableList>);
|
||||
};
|
||||
|
||||
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(<div></div>, 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);
|
||||
});
|
||||
});
|
||||
});
|
160
src/components/editable-list.jsx
Normal file
160
src/components/editable-list.jsx
Normal file
|
@ -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 = (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={item}
|
||||
onBlur={onInputBlur}
|
||||
onFocus={onInputFocus}
|
||||
onKeyDown={_.partial(onInputKeyDown, _, item, idx)} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
key={idx}
|
||||
onClick={_.partial(onClick, _, item, idx)}
|
||||
onDoubleClick={_.partial(onDoubleClick, _, item, idx)}>
|
||||
{itemToRender}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={`nylas-editable-list ${this.props.className}`}>
|
||||
<div
|
||||
className="items-wrapper"
|
||||
tabIndex="1"
|
||||
onBlur={this._onListBlur}
|
||||
onKeyDown={this._onListKeyDown}>
|
||||
{this._items.map((item, idx)=> this._renderItem(item, idx))}
|
||||
</div>
|
||||
<div className="buttons-wrapper">
|
||||
<button className="btn btn-small btn-editable-list" onClick={this._onCreateItem}>+</button>
|
||||
<button className="btn btn-small btn-editable-list" onClick={this._onDeleteItem}>—</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EditableList;
|
|
@ -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'
|
||||
|
|
50
static/components/editable-list.less
Normal file
50
static/components/editable-list.less
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,3 +28,4 @@
|
|||
@import "components/unsafe";
|
||||
@import "components/key-commands-region";
|
||||
@import "components/contenteditable";
|
||||
@import "components/editable-list";
|
||||
|
|
Loading…
Reference in a new issue