choo components (#639)

* add components

* example: componentize header

* fix choo SSR

* example: componentize footer

* example: fix footer

* update component cache asserts

* componentize todos

* reorder example dir

* fix example tests

* fix dep check test

* Add garbage collection of unused components

* Apply args when calling identity

* update to new component preview

* update nanocomponent

* fix cache err name

* remove static methods from example

* restore app.emit() function

* public API tests

* offset component cache iteration by 2

* whitelist contents of component folder (#643)

* allow lru number arg

* fix lint typo
This commit is contained in:
Yoshua Wuyts
2018-03-30 18:19:06 +02:00
committed by GitHub
parent 357d50871e
commit bddcfbe838
17 changed files with 411 additions and 259 deletions
+41
View File
@@ -0,0 +1,41 @@
var assert = require('assert')
var LRU = require('nanolru')
module.exports = ChooComponentCache
function ChooComponentCache (state, emit, lru) {
assert.ok(this instanceof ChooComponentCache, 'ChooComponentCache should be created with `new`')
assert.equal(typeof state, 'object', 'ChooComponentCache: state should be type object')
assert.equal(typeof emit, 'function', 'ChooComponentCache: emit should be type function')
if (typeof lru === 'number') this.cache = new LRU(lru)
else this.cache = lru || new LRU(100)
this.state = state
this.emit = emit
}
// Get & create component instances.
ChooComponentCache.prototype.render = function (Component, id) {
assert.equal(typeof Component, 'function', 'ChooComponentCache.render: Component should be type function')
assert.ok(typeof id === 'string' || typeof id === 'number', 'ChooComponentCache.render: id should be type string or type number')
var el = this.cache.get(id)
if (!el) {
var args = []
for (var i = 2, len = arguments.length; i < len; i++) {
args.push(arguments[i])
}
args.unshift(Component, id, this.state, this.emit)
el = newCall.apply(newCall, args)
this.cache.set(id, el)
}
return el
}
// Because you can't call `new` and `.apply()` at the same time. This is a mad
// hack, but hey it works so we gonna go for it. Whoop.
function newCall (Cls) {
return new (Cls.bind.apply(Cls, arguments)) // eslint-disable-line
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('nanocomponent')
+15
View File
@@ -0,0 +1,15 @@
var html = require('bel')
module.exports = deleteCompleted
function deleteCompleted (emit) {
return html`
<button class="clear-completed" onclick=${deleteAllCompleted}>
Clear completed
</button>
`
function deleteAllCompleted () {
emit('todos:deleteCompleted')
}
}
@@ -0,0 +1,18 @@
var html = require('bel')
module.exports = filterButton
function filterButton (name, filter, currentFilter, emit) {
var filterClass = filter === currentFilter
? 'selected'
: ''
var uri = '#' + name.toLowerCase()
if (uri === '#all') uri = '/'
return html`<li>
<a href=${uri} class=${filterClass}>
${name}
</a>
</li>`
}
+50
View File
@@ -0,0 +1,50 @@
var Component = require('../../../component')
var html = require('bel')
var clearButton = require('./clear-button')
var filterButton = require('./filter-button')
module.exports = class Footer extends Component {
constructor (name, state, emit) {
super(name)
this.state = state
this.emit = emit
this.local = this.state.components.footer = {}
this.setState()
}
setState () {
this.local.rawTodos = this.state.todos.clock
this.local.rawHref = this.state.href
this.local.filter = this.state.href.replace(/^\//, '') || ''
this.local.activeCount = this.state.todos.active.length
this.local.hasDone = this.state.todos.done.length || null
}
update () {
if (this.local.rawTodos !== this.state.todos.clock ||
this.local.rawHref !== this.state.href) {
this.setState()
return true
} else {
return false
}
}
createElement () {
return html`<footer class="footer">
<span class="todo-count">
<strong>${this.local.activeCount}</strong>
item${this.state.todos.all === 1 ? '' : 's'} left
</span>
<ul class="filters">
${filterButton('All', '', this.local.filter, this.emit)}
${filterButton('Active', 'active', this.local.filter, this.emit)}
${filterButton('Completed', 'completed', this.local.filter, this.emit)}
</ul>
${this.local.hasDone && clearButton(this.emit)}
</footer>`
}
}
+32
View File
@@ -0,0 +1,32 @@
var Component = require('../../component')
var html = require('bel')
module.exports = class Header extends Component {
constructor (name, state, emit) {
super(name)
this.state = state
this.emit = emit
}
update () {
return false
}
createElement () {
return html`<header class="header">
<h1>todos</h1>
<input class="new-todo"
autofocus
placeholder="What needs to be done?"
onkeydown=${this.createTodo.bind(this)} />
</header>`
}
createTodo (e) {
var value = e.target.value
if (e.keyCode === 13) {
e.target.value = ''
this.emit('todos:create', value)
}
}
}
+16
View File
@@ -0,0 +1,16 @@
var Component = require('../../component')
var html = require('bel')
module.exports = class Info extends Component {
update () {
return false
}
createElement () {
return html`<footer class="info">
<p>Double-click to edit a todo</p>
<p>choo by <a href="https://yoshuawuyts.com/">Yoshua Wuyts</a></p>
<p>Created by <a href="http://shuheikagawa.com">Shuhei Kagawa</a></p>
</footer>`
}
}
+65
View File
@@ -0,0 +1,65 @@
var Component = require('../../../component')
var html = require('bel')
var Todo = require('./todo')
module.exports = class Header extends Component {
constructor (name, state, emit) {
super(name)
this.state = state
this.emit = emit
this.local = this.state.components[name] = {}
this.setState()
}
setState () {
this.local.rawTodos = this.state.todos.clock
this.local.rawHref = this.state.href
this.local.allDone = this.state.todos.done.length === this.state.todos.all.length
this.local.filter = this.state.href.replace(/^\//, '') || ''
this.local.todos = this.local.filter === 'completed'
? this.state.todos.done
: this.local.filter === 'active'
? this.state.todos.active
: this.state.todos.all
}
update () {
if (this.local.rawTodos !== this.state.todos.clock ||
this.local.rawHref !== this.state.href) {
this.setState()
return true
} else {
return false
}
}
createElement () {
return html`<section class="main">
<input
class="toggle-all"
type="checkbox"
checked=${this.local.allDone}
onchange=${() => this.toggleAll()}/>
<label for="toggle-all" style="display: none;">
Mark all as done
</label>
<ul class="todo-list">
${this.local.todos.map(todo => Todo(todo, this.emit))}
</ul>
</section>`
}
createTodo (e) {
var value = e.target.value
if (e.keyCode === 13) {
e.target.value = ''
this.emit('todos:create', value)
}
}
toggleAll () {
this.emit('todos:toggleAll')
}
}
+64
View File
@@ -0,0 +1,64 @@
var html = require('bel')
module.exports = Todo
function Todo (todo, emit) {
var clx = classList({ completed: todo.done, editing: todo.editing })
return html`
<li id=${todo.id} class=${clx}>
<div class="view">
<input
type="checkbox"
class="toggle"
checked="${todo.done}"
onchange=${toggle} />
<label ondblclick=${edit}>${todo.name}</label>
<button
class="destroy"
onclick=${destroy}
></button>
</div>
<input
class="edit"
value=${todo.name}
onkeydown=${handleEditKeydown}
onblur=${update} />
</li>
`
function toggle (e) {
emit('todos:toggle', todo.id)
}
function edit (e) {
emit('todos:edit', todo.id)
}
function destroy (e) {
emit('todos:delete', todo.id)
}
function update (e) {
emit('todos:update', {
id: todo.id,
editing: false,
name: e.target.value
})
}
function handleEditKeydown (e) {
if (e.keyCode === 13) update(e) // Enter
else if (e.code === 27) emit('todos:unedit') // Escape
}
function classList (classes) {
var str = ''
var keys = Object.keys(classes)
for (var i = 0, len = keys.length; i < len; i++) {
var key = keys[i]
var val = classes[key]
if (val) str += (key + ' ')
}
return str
}
}
+5 -5
View File
@@ -8,11 +8,11 @@ var app = choo()
if (process.env.NODE_ENV !== 'production') {
app.use(require('choo-devtools')())
}
app.use(require('./store'))
app.use(require('./stores/todos'))
app.route('/', require('./view'))
app.route('#active', require('./view'))
app.route('#completed', require('./view'))
app.route('*', require('./view'))
app.route('/', require('./views/main'))
app.route('#active', require('./views/main'))
app.route('#completed', require('./views/main'))
app.route('*', require('./views/main'))
module.exports = app.mount('body')
+18 -29
View File
@@ -1,30 +1,18 @@
module.exports = todoStore
function todoStore (state, emitter) {
if (!state.todos) {
state.todos = {}
state.todos.active = []
state.todos.done = []
state.todos.all = []
state.todos.idCounter = 0
state.todos = {
clock: 0,
idCounter: 0,
active: [],
done: [],
all: []
}
// Always reset when application boots
state.todos.input = ''
// Register emitters after DOM is loaded to speed up DOM loading
emitter.on('DOMContentLoaded', function () {
// CRUD
emitter.on('todos:create', create)
emitter.on('todos:update', update)
emitter.on('todos:delete', del)
// Special
emitter.on('todos:input', oninput)
// Shorthand
emitter.on('todos:edit', edit)
emitter.on('todos:unedit', unedit)
emitter.on('todos:toggle', toggle)
@@ -32,10 +20,6 @@ function todoStore (state, emitter) {
emitter.on('todos:deleteCompleted', deleteCompleted)
})
function oninput (text) {
state.todos.input = text
}
function create (name) {
var item = {
id: state.todos.idCounter,
@@ -47,21 +31,21 @@ function todoStore (state, emitter) {
state.todos.idCounter += 1
state.todos.active.push(item)
state.todos.all.push(item)
emitter.emit('render')
render()
}
function edit (id) {
state.todos.all.forEach(function (todo) {
if (todo.id === id) todo.editing = true
})
emitter.emit('render')
render()
}
function unedit (id) {
state.todos.all.forEach(function (todo) {
if (todo.id === id) todo.editing = false
})
emitter.emit('render')
render()
}
function update (newTodo) {
@@ -78,7 +62,7 @@ function todoStore (state, emitter) {
}
Object.assign(todo, newTodo)
emitter.emit('render')
render()
}
function del (id) {
@@ -111,7 +95,7 @@ function todoStore (state, emitter) {
})
active.splice(activeIndex, 1)
}
emitter.emit('render')
render()
}
function deleteCompleted (data) {
@@ -121,7 +105,7 @@ function todoStore (state, emitter) {
state.todos.all.splice(index, 1)
})
state.todos.done = []
emitter.emit('render')
render()
}
function toggle (id) {
@@ -135,7 +119,7 @@ function todoStore (state, emitter) {
var index = arr.indexOf(todo)
arr.splice(index, 1)
target.push(todo)
emitter.emit('render')
render()
}
function toggleAll (data) {
@@ -155,6 +139,11 @@ function todoStore (state, emitter) {
state.todos.active = []
}
render()
}
function render () {
state.todos.clock += 1
emitter.emit('render')
}
}
+1 -20
View File
@@ -2,7 +2,7 @@ var EventEmitter = require('events').EventEmitter
var spok = require('spok')
var tape = require('tape')
var todoStore = require('./store')
var todoStore = require('./stores/todos')
tape('should initialize empty state', function (t) {
var emitter = new EventEmitter()
@@ -19,25 +19,6 @@ tape('should initialize empty state', function (t) {
t.end()
})
tape('restore previous state', function (t) {
var emitter = new EventEmitter()
var state = {
todos: {
idCounter: 100,
active: [],
done: [],
all: []
}
}
todoStore(state, emitter)
spok(t, state, {
todos: {
idCounter: 100
}
})
t.end()
})
tape('todos:create', function (t) {
var emitter = new EventEmitter()
var state = {}
-190
View File
@@ -1,190 +0,0 @@
var html = require('bel') // cannot require choo/html because it's a nested repo
module.exports = mainView
function mainView (state, emit) {
emit('log:debug', 'Rendering main view')
return html`
<body>
<section class="todoapp">
${Header(state, emit)}
${TodoList(state, emit)}
${Footer(state, emit)}
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
<p>choo by <a href="https://yoshuawuyts.com/">Yoshua Wuyts</a></p>
<p>Created by <a href="http://shuheikagawa.com">Shuhei Kagawa</a></p>
</footer>
</body>
`
}
function Header (state, emit) {
return html`
<header class="header">
<h1>todos</h1>
<input class="new-todo"
value=${state.todos.input}
autofocus
placeholder="What needs to be done?"
onkeydown=${createTodo} />
</header>
`
function createTodo (e) {
var value = e.target.value
if (!value) return
if (e.keyCode === 13) {
emit('todos:input', '')
emit('todos:create', value)
} else {
emit('todos:input', value)
}
}
}
function Footer (state, emit) {
var filter = state.href.replace(/^\//, '') || ''
var activeCount = state.todos.active.length
var hasDone = state.todos.done.length
return html`
<footer class="footer">
<span class="todo-count">
<strong>${activeCount}</strong>
item${state.todos.all === 1 ? '' : 's'} left
</span>
<ul class="filters">
${filterButton('All', '', filter, emit)}
${filterButton('Active', 'active', filter, emit)}
${filterButton('Completed', 'completed', filter, emit)}
</ul>
${hasDone ? deleteCompleted(emit) : ''}
</footer>
`
function filterButton (name, filter, currentFilter, emit) {
var filterClass = filter === currentFilter
? 'selected'
: ''
var uri = '#' + name.toLowerCase()
if (uri === '#all') uri = '/'
return html`
<li>
<a href=${uri} class=${filterClass}>
${name}
</a>
</li>
`
}
function deleteCompleted (emit) {
return html`
<button class="clear-completed" onclick=${deleteAllCompleted}>
Clear completed
</button>
`
function deleteAllCompleted () {
emit('todos:deleteCompleted')
}
}
}
function TodoItem (todo, emit) {
var clx = classList({ completed: todo.done, editing: todo.editing })
return html`
<li id=${todo.id} class=${clx}>
<div class="view">
<input
type="checkbox"
class="toggle"
checked="${todo.done}"
onchange=${toggle} />
<label ondblclick=${edit}>${todo.name}</label>
<button
class="destroy"
onclick=${destroy}
></button>
</div>
<input
class="edit"
value=${todo.name}
onkeydown=${handleEditKeydown}
onblur=${update} />
</li>
`
function toggle (e) {
emit('todos:toggle', todo.id)
}
function edit (e) {
emit('todos:edit', todo.id)
}
function destroy (e) {
emit('todos:delete', todo.id)
}
function update (e) {
emit('todos:update', {
id: todo.id,
editing: false,
name: e.target.value
})
}
function handleEditKeydown (e) {
if (e.keyCode === 13) update(e) // Enter
else if (e.code === 27) emit('todos:unedit') // Escape
}
function classList (classes) {
var str = ''
var keys = Object.keys(classes)
for (var i = 0, len = keys.length; i < len; i++) {
var key = keys[i]
var val = classes[key]
if (val) str += (key + ' ')
}
return str
}
}
function TodoList (state, emit) {
var filter = state.href.replace(/^\//, '') || ''
var items = filter === 'completed'
? state.todos.done
: filter === 'active'
? state.todos.active
: state.todos.all
var allDone = state.todos.done.length === state.todos.all.length
var nodes = items.map(function (todo) {
return TodoItem(todo, emit)
})
return html`
<section class="main">
<input
class="toggle-all"
type="checkbox"
checked=${allDone}
onchange=${toggleAll}/>
<label for="toggle-all" style="display: none;">
Mark all as done
</label>
<ul class="todo-list">
${nodes}
</ul>
</section>
`
function toggleAll () {
emit('todos:toggleAll')
}
}
+21
View File
@@ -0,0 +1,21 @@
var html = require('bel') // cannot require choo/html because it's a nested repo
var Header = require('../components/header')
var Footer = require('../components/footer')
var Todos = require('../components/todos')
var Info = require('../components/info')
module.exports = mainView
function mainView (state, emit) {
return html`
<body>
<section class="todoapp">
${state.cache(Header, 'header').render()}
${state.cache(Todos, 'todos').render()}
${state.cache(Footer, 'footer').render()}
</section>
${state.cache(Info, 'info').render()}
</body>
`
}
+43 -14
View File
@@ -11,6 +11,8 @@ var nanobus = require('nanobus')
var assert = require('assert')
var xtend = require('xtend')
var Cache = require('./component/cache')
module.exports = Choo
var HISTORY_OBJECT = {}
@@ -39,25 +41,30 @@ function Choo (opts) {
this._hrefEnabled = opts.href === undefined ? true : opts.href
this._hasWindow = typeof window !== 'undefined'
this._createLocation = nanolocation
this._cache = opts.cache
this._loaded = false
this._stores = []
this._tree = null
// properties that are part of the API
this.router = nanorouter()
this.emitter = nanobus('choo.emit')
this.emit = this.emitter.emit.bind(this.emitter)
var events = { events: this._events }
// state
var _state = {
events: this._events,
components: {}
}
if (this._hasWindow) {
this.state = window.initialState
? xtend(window.initialState, events)
: events
? xtend(window.initialState, _state)
: _state
delete window.initialState
} else {
this.state = events
this.state = _state
}
// properties that are part of the API
this.router = nanorouter({ curry: true })
this.emitter = nanobus('choo.emit')
this.emit = this.emitter.emit.bind(this.emitter)
// listen for title changes; available even when calling .toString()
if (this._hasWindow) this.state.title = document.title
this.emitter.prependListener(this._events.DOMTITLECHANGE, function (title) {
@@ -76,11 +83,11 @@ Choo.prototype.route = function (route, handler) {
Choo.prototype.use = function (cb) {
assert.equal(typeof cb, 'function', 'choo.use: cb should be type function')
var self = this
this._stores.push(function () {
this._stores.push(function (state) {
var msg = 'choo.use'
msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg
var endTiming = nanotiming(msg)
cb(self.state, self.emitter, self)
cb(state, self.emitter, self)
endTiming()
})
}
@@ -128,8 +135,9 @@ Choo.prototype.start = function () {
}
}
this._setCache(this.state)
this._stores.forEach(function (initStore) {
initStore()
initStore(self.state)
})
this._matchRoute()
@@ -200,9 +208,10 @@ Choo.prototype.toString = function (location, state) {
assert.equal(typeof location, 'string', 'choo.toString: location should be type string')
assert.equal(typeof this.state, 'object', 'choo.toString: state should be type object')
// TODO: pass custom state down to each store.
var self = this
this._setCache(this.state)
this._stores.forEach(function (initStore) {
initStore()
initStore(self.state)
})
this._matchRoute(location)
@@ -236,3 +245,23 @@ Choo.prototype._prerender = function (state) {
routeTiming()
return res
}
Choo.prototype._setCache = function (state) {
var cache = new Cache(state, this.emitter.emit.bind(this.emitter), this._cache)
state.cache = renderComponent
function renderComponent (Component, id) {
assert.equal(typeof Component, 'function', 'choo.state.cache: Component should be type function')
var args = []
for (var i = 0, len = arguments.length; i < len; i++) {
args.push(arguments[i])
}
return cache.render.apply(cache, args)
}
// When the state gets stringified, make sure `state.cache` isn't
// stringified too.
renderComponent.toJSON = function () {
return null
}
}
+5 -1
View File
@@ -9,12 +9,14 @@
"html/index.js",
"html/raw.js",
"html/index.d.ts",
"component/cache.js",
"component/index.js",
"dist",
"example"
],
"scripts": {
"build": "mkdir -p dist/ && browserify index -p bundle-collapser/plugin > dist/bundle.js && browserify index -p tinyify > dist/bundle.min.js && cat dist/bundle.min.js | gzip --best --stdout | wc -c | pretty-bytes",
"deps": "dependency-check --entry ./html/index.js . && dependency-check . --extra --no-dev --entry ./html/index.js",
"deps": "dependency-check --entry ./html/index.js . && dependency-check . --extra --no-dev --entry ./html/index.js --entry ./component/index.js",
"inspect": "browserify --full-paths index -g unassertify -g uglifyify | discify --open",
"prepublishOnly": "npm run build",
"start": "bankai start example",
@@ -34,8 +36,10 @@
"bel": "^5.1.3",
"document-ready": "^2.0.1",
"nanobus": "^4.2.0",
"nanocomponent": "^6.5.0",
"nanohref": "^3.0.0",
"nanolocation": "^1.0.0",
"nanolru": "^1.0.0",
"nanomorph": "^5.1.2",
"nanoquery": "^1.1.0",
"nanoraf": "^3.0.0",
+16
View File
@@ -29,3 +29,19 @@ tape('should render on the server with hyperscript', function (t) {
t.equal(res.toString().trim(), exp, 'result was OK')
t.end()
})
tape('should expose a public API', function (t) {
var app = choo()
t.equal(typeof app.route, 'function', 'app.route prototype method exists')
t.equal(typeof app.toString, 'function', 'app.toString prototype method exists')
t.equal(typeof app.start, 'function', 'app.start prototype method exists')
t.equal(typeof app.mount, 'function', 'app.mount prototype method exists')
t.equal(typeof app.emitter, 'object', 'app.emitter prototype method exists')
t.equal(typeof app.emit, 'function', 'app.emit instance method exists')
t.equal(typeof app.router, 'object', 'app.router instance object exists')
t.equal(typeof app.state, 'object', 'app.state instance object exists')
t.end()
})