mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-07 21:24:24 +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"]}],
|
"react/prop-types": [2, {"ignore": ["children"]}],
|
||||||
"eqeqeq": [2, "smart"],
|
"eqeqeq": [2, "smart"],
|
||||||
"id-length": [0],
|
"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 "TimeoutTransitionGroup", 'timeout-transition-group'
|
||||||
@load "ConfigPropContainer", "config-prop-container"
|
@load "ConfigPropContainer", "config-prop-container"
|
||||||
@load "DisclosureTriangle", "disclosure-triangle"
|
@load "DisclosureTriangle", "disclosure-triangle"
|
||||||
|
@load "EditableList", "editable-list"
|
||||||
|
|
||||||
@load "ScrollRegion", 'scroll-region'
|
@load "ScrollRegion", 'scroll-region'
|
||||||
@load "ResizableRegion", 'resizable-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/unsafe";
|
||||||
@import "components/key-commands-region";
|
@import "components/key-commands-region";
|
||||||
@import "components/contenteditable";
|
@import "components/contenteditable";
|
||||||
|
@import "components/editable-list";
|
||||||
|
|
Loading…
Add table
Reference in a new issue