diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6112603 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules + +npm-debug.log diff --git a/example/components/li.js b/example/components/li.js new file mode 100644 index 0000000..549c461 --- /dev/null +++ b/example/components/li.js @@ -0,0 +1,29 @@ +const h = require('snabbdom/h').default; + +module.exports = function Li({ emitter, props }) { + function store() { + const state = { + value: props.value + }; + + emitter.on('item:change', () => { + state.value = Date.now(); + emitter.emit('self:update'); + }); + + return state; + } + + function change() { + emitter.emit('item:change'); + } + + function view({ state }) { + return h('li', { on: { click: change } }, state.value); + } + + return { + store, + view + }; +}; diff --git a/example/components/list.js b/example/components/list.js new file mode 100644 index 0000000..c1beb3a --- /dev/null +++ b/example/components/list.js @@ -0,0 +1,45 @@ +const Li = require('./li'); +const h = require('snabbdom/h').default; + +module.exports = function List({ emitter, component, props }) { + function store() { + const state = { + items: props.items + }; + + emitter.on('item:add', (e) => { + state.items.push(e.value); + emitter.emit('self:update'); + }); + + emitter.on('item:remove', () => { + state.items.pop(); + emitter.emit('self:update'); + }); + + return state; + } + + function add() { + emitter.emit('item:add', { value: Date.now() }); + } + + function remove() { + emitter.emit('item:remove'); + } + + const li = component(Li); + + function view({ state }) { + return h('div#list', [ + h('button', { on: { click: add } }, 'add'), + h('button', { on: { click: remove } }, 'remove'), + h('ul', state.items.map((value, key) => li({ value, key }))) + ]); + } + + return { + store, + view + }; +}; diff --git a/example/index.js b/example/index.js new file mode 100644 index 0000000..414fa0b --- /dev/null +++ b/example/index.js @@ -0,0 +1,42 @@ +const { start } = require('../index'); +const List = require('./components/list'); +const h = require('snabbdom/h').default; + +function App({ emitter, component }) { + function store() { + const state = { + time: Date.now() + }; + + emitter.on('change:time', (e) => { + state.time = e.time; + emitter.emit('self:update'); + }); + + return state; + } + + const change = () => { + emitter.emit('change:time', { time: Date.now() }); + }; + + function view({ state }) { + return h('div#app', [ + h('h1', { on: { click: change } }, state.time), + component(List, { items: ['test 1', 'test 2'] }) + ]); + } + + return { + store, + view + }; +} + +window.addEventListener('DOMContentLoaded', () => { + const container = document.createElement('div'); + container.id = 'app'; + document.body.insertBefore(container, document.body.firstChild); + + start(container, App); +}); diff --git a/index.js b/index.js new file mode 100644 index 0000000..bca75e8 --- /dev/null +++ b/index.js @@ -0,0 +1,23 @@ +const mitt = require('mitt'); +const createRenderer = require('./lib/create-renderer'); +const { component } = require('./lib/component'); + +function start(container, createApp, props = {}) { + const render = createRenderer(container); + + const emitter = mitt(); + + emitter.on('self:update', () => { + render({ view, state, props }); + }); + + const { view, store } = createApp({ emitter, component: component(emitter), props }); + + const state = store(); + + emitter.emit('self:update'); + + return emitter; +} + +exports.start = start; diff --git a/lib/component.js b/lib/component.js new file mode 100644 index 0000000..0f12c26 --- /dev/null +++ b/lib/component.js @@ -0,0 +1,61 @@ +const mitt = require('mitt'); +const give = require('xet'); +const curry = require('curry'); +const DeepMap = require('./deepmap'); +const createRenderer = require('./create-renderer'); +const noop = () => {}; +const instances = new DeepMap(); + +function defineHooks(emitter, vnode) { + vnode.data.hook = vnode.data.hook || {}; + + const userInsert = vnode.data.hook.insert || noop; + const userPostpatch = vnode.data.hook.postpatch || noop; + + vnode.data.hook.insert = vnode => { + vnode.emitter = emitter; + userInsert(vnode); + }; + + vnode.data.hook.postpatch = (oldVnode, vnode) => { + const children = vnode.elm.parentElement.vnode.children; + children[children.indexOf(oldVnode)] = vnode; + userPostpatch(oldVnode, vnode); + }; + + return vnode; +} + +function createComponent(factory, { props = {} }) { + const render = createRenderer(); + + const emitter = mitt(); + + emitter.on('self:update', () => { + render({ view, state, props }); + }); + + const { view: userView, store } = factory({ emitter, component: component(emitter), props }); + + const view = ({ state, props }) => { + return defineHooks(emitter, userView({ state, props })); + }; + + const state = store(); + + render({ view, state, props }); + + return render; +} + +const component = curry((emitter, factory, props) => { + const keys = [emitter, component, props.key]; + + const instance = give.call(instances, keys, () => createComponent(factory, { props })); + + return instance.getVnode(); +}); + +exports.defineHooks = defineHooks; +exports.createComponent = createComponent; +exports.component = component; diff --git a/lib/create-renderer.js b/lib/create-renderer.js new file mode 100644 index 0000000..b9353ef --- /dev/null +++ b/lib/create-renderer.js @@ -0,0 +1,26 @@ +const snabbdom = require('snabbdom'); +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners').default, + require('snabbdom/modules/style').default, + require('./remember-vnode') +], require('./htmldomapi')); + +module.exports = function createRenderer(container) { + let vnode = container; + + function render({ view, state, props }) { + if (vnode) { + vnode = patch(vnode, view({ state, props })); + } else { + vnode = view({ state, props }); + } + + return vnode; + } + + render.getVnode = () => { + return vnode; + }; + + return render; +}; diff --git a/lib/deepmap.js b/lib/deepmap.js new file mode 100644 index 0000000..e24e744 --- /dev/null +++ b/lib/deepmap.js @@ -0,0 +1,79 @@ +const give = require('xet'); +const root = new WeakMap(); +const leaves = new Map(); + +const deepClear = deepMap => { + if (leaves.has(deepMap)) { + leaves.delete(deepMap); + } + + for (let map of deepMap.values()) { + deepClear(map); + } + + return deepMap.clear(); +}; + +module.exports = class DeepMap { + constructor(entries = []) { + for (let entry of entries) { + this.set(...entry); + } + + root.set(this, new Map()); + } + + clear() { + return deepClear(this); + } + + delete(keys) { + let branch = root.get(this); + + for (let key of keys) { + if (branch.has(key)) { + branch = branch.get(key); + } else { + return false; + } + } + + return leaves.delete(branch); + } + + has(keys) { + let branch = root.get(this); + + for (let key of keys) { + if (branch.has(key)) { + branch = branch.get(key); + } else { + return false; + } + } + + return leaves.has(branch); + } + + get(keys) { + let branch = root.get(this); + + for (let key of keys) { + branch = branch.get(key); + } + + return leaves.get(branch); + } + + set(keys, value) { + let branch = root.get(this); + + for (let key of keys) { + branch = give.call(branch, key, () => new Map()); + } + + leaves.set(branch, value); + + return this; + } +}; diff --git a/lib/htmldomapi.js b/lib/htmldomapi.js new file mode 100644 index 0000000..80384fc --- /dev/null +++ b/lib/htmldomapi.js @@ -0,0 +1,10 @@ +const htmldomapi = require('snabbdom/htmldomapi').default; +const removeChild = htmldomapi.removeChild; + +htmldomapi.removeChild = (node, child) => { + if (node && child) { + removeChild(node, child); + } +}; + +module.exports = htmldomapi; diff --git a/lib/remember-vnode.js b/lib/remember-vnode.js new file mode 100644 index 0000000..6cc83a6 --- /dev/null +++ b/lib/remember-vnode.js @@ -0,0 +1,8 @@ +module.exports = { + create(oldVnode, vnode) { + vnode.elm.vnode = vnode; + }, + update(oldVnode, vnode) { + vnode.elm.vnode = vnode; + } +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..e811bc5 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "snabbmitt", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "test": "eslint .", + "start": "budo example/index.js --live --open" + }, + "keywords": [], + "author": "", + "license": "ISC", + "eslintConfig": { + "extends": "postcss", + "env": { + "browser": true, + "node": true + }, + "rules": { + "max-len": 0, + "no-use-before-define": 0, + "no-shadow": 0 + } + }, + "dependencies": { + "curry": "^1.2.0", + "mitt": "^1.1.0", + "snabbdom": "^0.6.6", + "xet": "^1.0.4" + }, + "devDependencies": { + "budo": "^9.4.7", + "eslint": "^3.17.1", + "eslint-config-postcss": "^2.0.2" + } +}