Fix backbone todo example bugs.

Fixed:
- New todo not submitting correctly (page refreshes. `preventDefault`
wasn't there.
- Old checked todo being removed will leave the checkmark on the next
todo replacing its position.
- Cannot change todo (`value`'s now a controlled field).
- `autofocus` (should be `autoFocus`, how ironic given the current
situation) on new todo input isn't working. Switched to manual
`focus()` in `componentDidMount` for now.
- More consistent breathing space between lines.
- Gutter at 80.

Added:
- Use todomvc-common base.css. The old one had to change ids to
classes. No longer necessary.
- Give `cx` a better name and move it in `Utils`.
- Trim input upon finishing edit.
- Remove todo if the new edited value is empty.
- Submit edited todo value on input blur.
- README to explain the existence of this example. Being able to
maintain a non-compilant version allows nice deviations from the
todomvc specs, such as animations, in the future.
This commit is contained in:
Cheng Lou 2013-09-06 16:40:35 -04:00
parent 3cf14e8f9b
commit 78d305eb16
4 changed files with 309 additions and 113 deletions

View File

@ -0,0 +1,3 @@
# TodoMVC-Backbone
This is a lightweight version of TodoMVC. Its primary purpose is to demo the Backbone integration rather than being feature-complete (refer to `todomvc-director` for a full TodoMVC-compilant app).

View File

@ -34,7 +34,7 @@ body {
font-smoothing: antialiased;
}
.todoapp {
#todoapp {
background: #fff;
background: rgba(255, 255, 255, 0.9);
margin: 130px 0 40px 0;
@ -46,7 +46,7 @@ body {
0 25px 50px 0 rgba(0, 0, 0, 0.15);
}
.todoapp:before {
#todoapp:before {
content: '';
border-left: 1px solid #f5d6d6;
border-right: 1px solid #f5d6d6;
@ -57,16 +57,16 @@ body {
height: 100%;
}
.todoapp input::-webkit-input-placeholder {
#todoapp input::-webkit-input-placeholder {
font-style: italic;
}
.todoapp input:-moz-placeholder {
#todoapp input::-moz-placeholder {
font-style: italic;
color: #a9a9a9;
}
.todoapp h1 {
#todoapp h1 {
position: absolute;
top: -120px;
width: 100%;
@ -83,12 +83,12 @@ body {
text-rendering: optimizeLegibility;
}
.header {
#header {
padding-top: 15px;
border-radius: inherit;
}
.header:before {
#header:before {
content: '';
position: absolute;
top: 0;
@ -109,7 +109,7 @@ body {
border-top-right-radius: 1px;
}
.new-todo,
#new-todo,
.edit {
position: relative;
margin: 0;
@ -135,7 +135,7 @@ body {
font-smoothing: antialiased;
}
.new-todo {
#new-todo {
padding: 16px 16px 16px 60px;
border: none;
background: rgba(0, 0, 0, 0.02);
@ -143,17 +143,17 @@ body {
box-shadow: none;
}
.main {
#main {
position: relative;
z-index: 2;
border-top: 1px dotted #adadad;
}
.toggle-all-label {
label[for='toggle-all'] {
display: none;
}
.toggle-all {
#toggle-all {
position: absolute;
top: -42px;
left: -4px;
@ -162,50 +162,50 @@ body {
border: none; /* Mobile Safari */
}
.toggle-all:before {
#toggle-all:before {
content: '»';
font-size: 28px;
color: #d9d9d9;
padding: 0 25px 7px;
}
.toggle-all:checked:before {
#toggle-all:checked:before {
color: #737373;
}
.todo-list {
#todo-list {
margin: 0;
padding: 0;
list-style: none;
}
.todo-list li {
#todo-list li {
position: relative;
font-size: 24px;
border-bottom: 1px dotted #ccc;
}
.todo-list li:last-child {
#todo-list li:last-child {
border-bottom: none;
}
.todo-list li.editing {
#todo-list li.editing {
border-bottom: none;
padding: 0;
}
.todo-list li.editing .edit {
#todo-list li.editing .edit {
display: block;
width: 506px;
padding: 13px 17px 12px 17px;
margin: 0 0 0 43px;
}
.todo-list li.editing .view {
#todo-list li.editing .view {
display: none;
}
.todo-list li .toggle {
#todo-list li .toggle {
text-align: center;
width: 40px;
/* auto, since non-WebKit browsers doesn't support input styling */
@ -222,7 +222,7 @@ body {
appearance: none;
}
.todo-list li .toggle:after {
#todo-list li .toggle:after {
content: '✔';
line-height: 43px; /* 40 + a couple of pixels visual adjustment */
font-size: 20px;
@ -230,16 +230,17 @@ body {
text-shadow: 0 -1px 0 #bfbfbf;
}
.todo-list li .toggle:checked:after {
#todo-list li .toggle:checked:after {
color: #85ada7;
text-shadow: 0 1px 0 #669991;
bottom: 1px;
position: relative;
}
.todo-list li label {
#todo-list li label {
white-space: pre;
word-break: break-word;
padding: 15px;
padding: 15px 60px 15px 15px;
margin-left: 45px;
display: block;
line-height: 1.2;
@ -250,12 +251,12 @@ body {
transition: color 0.4s;
}
.todo-list li.completed label {
#todo-list li.completed label {
color: #a9a9a9;
text-decoration: line-through;
}
.todo-list li .destroy {
#todo-list li .destroy {
display: none;
position: absolute;
top: 0;
@ -273,7 +274,7 @@ body {
transition: all 0.2s;
}
.todo-list li .destroy:hover {
#todo-list li .destroy:hover {
text-shadow: 0 0 1px #000,
0 0 10px rgba(199, 107, 107, 0.8);
-webkit-transform: scale(1.3);
@ -283,23 +284,23 @@ body {
transform: scale(1.3);
}
.todo-list li .destroy:after {
#todo-list li .destroy:after {
content: '✖';
}
.todo-list li:hover .destroy {
#todo-list li:hover .destroy {
display: block;
}
.todo-list li .edit {
#todo-list li .edit {
display: none;
}
.todo-list li.editing:last-child {
#todo-list li.editing:last-child {
margin-bottom: -1px;
}
.footer {
#footer {
color: #777;
padding: 0 15px;
position: absolute;
@ -311,7 +312,7 @@ body {
text-align: center;
}
.footer:before {
#footer:before {
content: '';
position: absolute;
right: 0;
@ -326,12 +327,12 @@ body {
0 44px 2px -6px rgba(0, 0, 0, 0.2);
}
.todo-count {
#todo-count {
float: left;
text-align: left;
}
.filters {
#filters {
margin: 0;
padding: 0;
list-style: none;
@ -340,21 +341,21 @@ body {
left: 0;
}
.filters li {
#filters li {
display: inline;
}
.filters li a {
#filters li a {
color: #83756f;
margin: 2px;
text-decoration: none;
}
.filters li a.selected {
#filters li a.selected {
font-weight: bold;
}
.clear-completed {
#clear-completed {
float: right;
position: relative;
line-height: 20px;
@ -366,12 +367,12 @@ body {
box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.2);
}
.clear-completed:hover {
#clear-completed:hover {
background: rgba(0, 0, 0, 0.15);
box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.3);
}
.info {
#info {
margin: 65px auto 0;
color: #a6a6a6;
font-size: 12px;
@ -379,29 +380,25 @@ body {
text-align: center;
}
.info a {
#info a {
color: inherit;
}
.submitButton {
display: none;
}
/*
Hack to remove background from Mobile Safari.
Can't use it globally since it destroys checkboxes in Firefox and Opera
*/
@media screen and (-webkit-min-device-pixel-ratio:0) {
.toggle-all,
.todo-list li .toggle {
#toggle-all,
#todo-list li .toggle {
background: none;
}
.todo-list li .toggle {
#todo-list li .toggle {
height: 40px;
}
.toggle-all {
#toggle-all {
top: -56px;
left: -15px;
width: 65px;
@ -413,6 +410,147 @@ body {
}
}
.hidden{
display:none;
.hidden {
display: none;
}
hr {
margin: 20px 0;
border: 0;
border-top: 1px dashed #C5C5C5;
border-bottom: 1px dashed #F7F7F7;
}
.learn a {
font-weight: normal;
text-decoration: none;
color: #b83f45;
}
.learn a:hover {
text-decoration: underline;
color: #787e7e;
}
.learn h3,
.learn h4,
.learn h5 {
margin: 10px 0;
font-weight: 500;
line-height: 1.2;
color: #000;
}
.learn h3 {
font-size: 24px;
}
.learn h4 {
font-size: 18px;
}
.learn h5 {
margin-bottom: 0;
font-size: 14px;
}
.learn ul {
padding: 0;
margin: 0 0 30px 25px;
}
.learn li {
line-height: 20px;
}
.learn p {
font-size: 15px;
font-weight: 300;
line-height: 1.3;
margin-top: 0;
margin-bottom: 0;
}
.quote {
border: none;
margin: 20px 0 60px 0;
}
.quote p {
font-style: italic;
}
.quote p:before {
content: '“';
font-size: 50px;
opacity: .15;
position: absolute;
top: -20px;
left: 3px;
}
.quote p:after {
content: '”';
font-size: 50px;
opacity: .15;
position: absolute;
bottom: -42px;
right: 3px;
}
.quote footer {
position: absolute;
bottom: -40px;
right: 0;
}
.quote footer img {
border-radius: 3px;
}
.quote footer a {
margin-left: 5px;
vertical-align: middle;
}
.speech-bubble {
position: relative;
padding: 10px;
background: rgba(0, 0, 0, .04);
border-radius: 5px;
}
.speech-bubble:after {
content: '';
position: absolute;
top: 100%;
right: 30px;
border: 13px solid transparent;
border-top-color: rgba(0, 0, 0, .04);
}
/**body*/.learn-bar > .learn {
position: absolute;
width: 272px;
top: 8px;
left: -300px;
padding: 10px;
border-radius: 5px;
background-color: rgba(255, 255, 255, .6);
transition-property: left;
transition-duration: 500ms;
}
@media (min-width: 899px) {
/**body*/.learn-bar {
width: auto;
margin: 0 0 0 300px;
}
/**body*/.learn-bar > .learn {
left: 8px;
}
/**body*/.learn-bar #todoapp {
width: 550px;
margin: 130px auto 40px auto;
}
}

View File

@ -10,7 +10,7 @@
<![endif]-->
</head>
<body>
<div id="todoapp"></div>
<div id="container"></div>
<script src="../../build/react.js"></script>
<script src="../../build/JSXTransformer.js"></script>
<script type="text/javascript" src="../shared/thirdparty/jquery.min.js" charset="utf-8"></script>

View File

@ -1,20 +1,6 @@
/** @jsx React.DOM */
function cx(obj) {
var s = '';
for (var key in obj) {
if (!obj.hasOwnProperty(key)) {
continue;
}
if (obj[key]) {
s += key + ' ';
}
}
return s;
}
var Todo = Backbone.Model.extend({
// Default attributes for the todo
// and ensure that each todo created has `title` and `completed` keys.
defaults: {
@ -28,7 +14,6 @@ var Todo = Backbone.Model.extend({
completed: !this.get('completed')
});
}
});
var TodoList = Backbone.Collection.extend({
@ -68,6 +53,19 @@ var TodoList = Backbone.Collection.extend({
var Utils = {
pluralize: function( count, word ) {
return count === 1 ? word : word + 's';
},
stringifyObjKeys: function(obj) {
var s = '';
for (var key in obj) {
if (!obj.hasOwnProperty(key)) {
continue;
}
if (obj[key]) {
s += key + ' ';
}
}
return s;
}
};
@ -75,31 +73,47 @@ var Utils = {
var TodoItem = React.createClass({
handleSubmit: function(event) {
var val = this.refs.editField.getDOMNode().value;
var val = this.refs.editField.getDOMNode().value.trim();
if (val) {
this.props.onSave(val);
} else {
this.props.onDestroy();
}
return false;
},
onEdit: function() {
this.props.onEdit();
this.refs.editField.getDOMNode().focus();
},
render: function() {
var classes = Utils.stringifyObjKeys({
completed: this.props.todo.get('completed'), editing: this.props.editing
});
return (
<li class={cx({completed: this.props.todo.get('completed'), editing: this.props.editing})}>
<li class={classes}>
<div class="view">
<input
class="toggle"
type="checkbox"
checked={this.props.todo.get('completed') ? 'checked' : null}
checked={this.props.todo.get('completed')}
onChange={this.props.onToggle}
key={this.props.key}
/>
<label onDoubleClick={this.onEdit}>{this.props.todo.get('title')}</label>
<label onDoubleClick={this.onEdit}>
{this.props.todo.get('title')}
</label>
<button class="destroy" onClick={this.props.onDestroy} />
</div>
<form onSubmit={this.handleSubmit}>
<input ref="editField" class="edit" value={this.props.todo.get('title')} />
<input
ref="editField"
class="edit"
defaultValue={this.props.todo.get('title')}
onBlur={this.handleSubmit}
autoFocus="autofocus"
/>
</form>
</li>
);
@ -113,37 +127,46 @@ var TodoFooter = React.createClass({
if (this.props.completedCount > 0) {
clearButton = (
<button class="clear-completed" onClick={this.props.onClearCompleted}>Clear completed ({this.props.completedCount})</button>
<button id="clear-completed" onClick={this.props.onClearCompleted}>
Clear completed ({this.props.completedCount})
</button>
);
}
return (
<footer class="footer">
<span class="todo-count"><strong>{this.props.count}</strong>{' '}{activeTodoWord}{' '}left</span>
<footer id="footer">
<span id="todo-count">
<strong>{this.props.count}</strong>{' '}
{activeTodoWord}{' '}left
</span>
{clearButton}
</footer>
);
}
});
// An example generic Mixin that you can add to any component that should react to changes in a Backbone component.
// The use cases we've identified thus far are for Collections -- since they trigger a change event whenever
// any of their constituent items are changed there's no need to reconcile for regular models.
// One caveat: this relies on getBackboneModels() to always return the same model instances throughout the
// lifecycle of the component. If you're using this mixin correctly (it should be near the top of your
// component hierarchy) this should not be an issue.
// An example generic Mixin that you can add to any component that should react
// to changes in a Backbone component. The use cases we've identified thus far
// are for Collections -- since they trigger a change event whenever any of
// their constituent items are changed there's no need to reconcile for regular
// models. One caveat: this relies on getBackboneModels() to always return the
// same model instances throughout the lifecycle of the component. If you're
// using this mixin correctly (it should be near the top of your component
// hierarchy) this should not be an issue.
var BackboneMixin = {
componentDidMount: function() {
// Whenever there may be a change in the Backbone data, trigger a reconcile.
this.getBackboneModels().map(function(model) {
model.on('add change remove', this.forceUpdate, this);
}.bind(this));
this.getBackboneModels().forEach(function(model) {
model.on('add change remove', this.forceUpdate.bind(this, null), this);
}, this);
},
componentWillUnmount: function() {
// Ensure that we clean up any dangling references when the component is destroyed.
this.getBackboneModels().map(function(model) {
// Ensure that we clean up any dangling references when the component is
// destroyed.
this.getBackboneModels().forEach(function(model) {
model.off(null, null, this);
}.bind(this));
}, this);
}
};
@ -152,21 +175,28 @@ var TodoApp = React.createClass({
getInitialState: function() {
return {editing: null};
},
componentDidMount: function() {
// Additional functionality for todomvc: fetch() the collection on init
this.props.todos.fetch();
this.refs.newField.getDOMNode().focus();
},
componentDidUpdate: function() {
// If saving were expensive we'd listen for mutation events on Backbone and do this manually.
// however, since saving isn't expensive this is an elegant way to keep it reactively up-to-date.
this.props.todos.map(function(todo) {
// If saving were expensive we'd listen for mutation events on Backbone and
// do this manually. however, since saving isn't expensive this is an
// elegant way to keep it reactively up-to-date.
this.props.todos.forEach(function(todo) {
todo.save();
});
},
getBackboneModels: function() {
return [this.props.todos];
},
handleSubmit: function() {
handleSubmit: function(event) {
event.preventDefault();
var val = this.refs.newField.getDOMNode().value.trim();
if (val) {
this.props.todos.create({
@ -176,48 +206,63 @@ var TodoApp = React.createClass({
});
this.refs.newField.getDOMNode().value = '';
}
return false;
},
toggleAll: function(event) {
var checked = event.nativeEvent.target.checked;
this.props.todos.map(function(todo) {
this.props.todos.forEach(function(todo) {
todo.set('completed', checked);
});
},
destroy: function(todo) {
this.props.todos.remove(todo);
},
edit: function(todo) {
this.setState({editing: todo.get('id')});
},
save: function(todo, text) {
todo.set('title', text);
this.setState({editing: null});
},
clearCompleted: function() {
this.props.todos.completed().map(function(todo) {
this.props.todos.completed().forEach(function(todo) {
todo.destroy();
});
},
render: function() {
var footer = null;
var main = null;
var todoItems = this.props.todos.map(function(todo) {
return <TodoItem todo={todo} onToggle={todo.toggle.bind(todo)} onDestroy={this.destroy.bind(this, todo)} onEdit={this.edit.bind(this, todo)} editing={this.state.editing === todo.get('id')} onSave={this.save.bind(this, todo)} />;
}.bind(this));
return (
<TodoItem
key={Math.random()}
todo={todo}
onToggle={todo.toggle.bind(todo)}
onDestroy={todo.destroy.bind(todo)}
onEdit={this.edit.bind(this, todo)}
editing={this.state.editing === todo.get('id')}
onSave={this.save.bind(this, todo)}
/>
);
}, this);
var activeTodoCount = this.props.todos.remaining().length;
var completedCount = todoItems.length - activeTodoCount;
if (activeTodoCount || completedCount) {
footer = <TodoFooter count={activeTodoCount} completedCount={completedCount} onClearCompleted={this.clearCompleted} />;
if (activeTodoCount || completedCount) {
footer =
<TodoFooter
count={activeTodoCount}
completedCount={completedCount}
onClearCompleted={this.clearCompleted}
/>;
}
if (todoItems.length) {
main = (
<section class="main">
<input class="toggle-all" type="checkbox" onChange={this.toggleAll} />
<label class="toggle-all-label">Mark all as complete</label>
<ul class="todo-list">
<section id="main">
<input id="toggle-all" type="checkbox" onChange={this.toggleAll} />
<ul id="todo-list">
{todoItems}
</ul>
</section>
@ -226,23 +271,33 @@ var TodoApp = React.createClass({
return (
<div>
<section class="todoapp">
<header class="header">
<section id="todoapp">
<header id="header">
<h1>todos</h1>
<form onSubmit={this.handleSubmit}>
<input ref="newField" class="new-todo" placeholder="What needs to be done?" autofocus="autofocus" />
<input
ref="newField"
id="new-todo"
placeholder="What needs to be done?"
/>
</form>
</header>
{main}
{footer}
</section>
<footer class="info">
<footer id="info">
<p>Double-click to edit a todo</p>
<p>Created by{' '}<a href="http://github.com/petehunt/">petehunt</a></p>
<p>
Created by{' '}
<a href="http://github.com/petehunt/">petehunt</a>
</p>
<p>Part of{' '}<a href="http://todomvc.com">TodoMVC</a></p>
</footer>
</div>
);
}
});
React.renderComponent(<TodoApp todos={new TodoList()} />, document.getElementById('todoapp'));
React.renderComponent(
<TodoApp todos={new TodoList()} />, document.getElementById('container')
);