├── .gitignore ├── .npmignore ├── .npmrc ├── .travis.yml ├── README.md ├── cache ├── README.md └── index.js ├── examples ├── expandable │ ├── index.css │ ├── index.js │ └── package.json ├── list │ ├── index.css │ ├── index.js │ └── package.json └── mapbox │ ├── index.css │ ├── index.js │ └── package.json ├── index.js ├── logger ├── README.md └── index.js ├── package.json ├── restate ├── README.md └── index.js ├── spawn ├── README.md └── index.js └── test ├── browser.js └── node.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /examples 2 | /test 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | - "5" 5 | - "6" 6 | - "7" 7 | - "8" 8 | addons: 9 | apt: 10 | packages: 11 | - xvfb 12 | install: 13 | - export DISPLAY=':99.0' 14 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 15 | - npm install 16 | script: 17 | - npm test 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # fun-component `<🙂/>` 4 | 5 | [![npm version](https://img.shields.io/npm/v/fun-component.svg?style=flat-square)](https://npmjs.org/package/fun-component) [![build status](https://img.shields.io/travis/tornqvist/fun-component/master.svg?style=flat-square)](https://travis-ci.org/tornqvist/fun-component) 6 | [![downloads](http://img.shields.io/npm/dm/fun-component.svg?style=flat-square)](https://npmjs.org/package/fun-component) 7 | [![style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](https://npmjs.org/package/fun-component) 8 | 9 |
10 | 11 | Performant and functional HTML components with plugins. Syntactic suggar on top of [nanocomponent](https://github.com/choojs/nanocomponent). 12 | 13 | - [Usage](#usage) 14 | - [API](#api) 15 | - [Lifecycle events](#lifecycle-events) 16 | - [Plugins](#plugins) 17 | - [Composition and forking](#composition-and-forking) 18 | - [Examples](#examples) 19 | - [Why tho?](#why-tho) 20 | 21 | ## Usage 22 | 23 | Pass in a function and get another one back that handles rerendering. 24 | 25 | ```javascript 26 | // button.js 27 | var html = require('nanohtml') 28 | var component = require('fun-component') 29 | 30 | var button = module.exports = component(function button (ctx, clicks, onclick) { 31 | return html` 32 | 35 | ` 36 | }) 37 | 38 | // only bother updating if text changed 39 | button.on('update', function (ctx, [clicks], [prev]) { 40 | return clicks !== prev 41 | }) 42 | ``` 43 | 44 | ```javascript 45 | // app.js 46 | var choo = require('choo') 47 | var html = require('choo/html') 48 | var button = require('./button') 49 | 50 | var app = choo() 51 | app.route('/', view) 52 | app.mount('body') 53 | 54 | function view (state, emit) { 55 | return html` 56 | 57 | ${button(state.clicks, () => emit('emit'))} 58 | 59 | ` 60 | } 61 | 62 | app.use(function (state, emitter) { 63 | state.clicks = 0 64 | emitter.on('click', function () { 65 | state.clicks += 1 66 | emitter.emit('render') 67 | }) 68 | }) 69 | ``` 70 | 71 | ### Standalone 72 | 73 | Though fun-component was authored with [choo](https://github.com/choojs/choo) in mind it works just as well standalone. 74 | 75 | ```javascript 76 | var button = require('./button') 77 | 78 | var clicks = 0 79 | function onclick () { 80 | clicks += 1 81 | button(clicks, onclick) 82 | } 83 | 84 | document.body.appendChild(button(clicks, onclick)) 85 | ``` 86 | 87 | ## API 88 | 89 | ### `component([name], render)` 90 | 91 | Create a new component context. Either takes a function as an only argument or a name and a function. Returns a function that renders the element. If no name is supplied the name is derrived from the functions `name` property. 92 | 93 | *Warning: implicit function names are most probably mangled during minification. If name consistency is important to your implementation, use the explicit name syntax.* 94 | 95 | ```javascript 96 | var button = component('button', (text) => html``) 97 | ``` 98 | 99 | #### `button.on(name, fn)` 100 | 101 | Add lifecycle event listener, see [Lifecycle events](#lifecycle-events). 102 | 103 | #### `button.off(name, fn)` 104 | 105 | Remove lifecycle eventlistener, see [Lifecycle events](#lifecycle-events). 106 | 107 | #### `button.use(fn)` 108 | 109 | Add plugin, see [Plugins](#plugins). 110 | 111 | #### `button.fork(name)` 112 | 113 | Create a new component context inheriting listeners and plugins, see [Composition and forking](#composition-and-forking) 114 | 115 | ### Lifecycle events 116 | 117 | All the lifecycle hooks of nanocomponent are supported, i.e. [`beforerender`](https://github.com/choojs/nanocomponent#nanocomponentprototypebeforerenderel), [`load`](https://github.com/choojs/nanocomponent#nanocomponentprototypeloadel), [`unload`](https://github.com/choojs/nanocomponent#nanocomponentprototypeunloadel), [`afterupdate`](https://github.com/choojs/nanocomponent#nanocomponentprototypeafterupdateel), and [`afterreorder`](https://github.com/choojs/nanocomponent#nanocomponentprototypeafterreorderel). Any number of listeners can be added for an event. The arguments are always prefixed with the component context and the element, followed by the render arguments. 118 | 119 | ```javascript 120 | var html = require('nanohtml') 121 | var component = require('fun-component') 122 | 123 | var greeting = component(function greeting (ctx, name) { 124 | return html`

Hello ${name}!

` 125 | }) 126 | 127 | greeting.on('load', function (ctx, el, name) { 128 | console.log(`element ${name} is now in the DOM`) 129 | } 130 | 131 | greeting.on('afterupdate', function (ctx, el, name) { 132 | console.log(`element ${name} was updated`) 133 | } 134 | 135 | document.body.appendChild(greeting('world')) 136 | greeting('planet') 137 | ``` 138 | 139 | #### Context 140 | 141 | The component context (`ctx`) is prefixed to the arguments of all lifecycle events and the render function itself. The context object can be used to access the underlying [nanocomponent](https://github.com/choojs/nanocomponent). 142 | 143 | ```javascript 144 | var html = require('nanohtml') 145 | var component = require('fun-component') 146 | 147 | // exposing nanocomponent inner workings 148 | module.exports = component(function time (ctx) { 149 | return html` 150 |
151 | The time is ${new Date()} 152 | 153 |
154 | ` 155 | }) 156 | ``` 157 | 158 | #### Update 159 | 160 | fun-component comes with a baked in default update function that performs a shallow diff of arguments to determine whether to update the component. By listening for the `update` event you may override this default behavior. 161 | 162 | If you attach several `update` listerners the component will update if *any one* of them return `true`. 163 | 164 | ***Note**: Opposed to how nanocomponent calls the update function to determine whether to rerender the component, fun-component not only supplies the next arguments but also the previous arguments. These two can then be compared to determine whether to update.* 165 | 166 | ***Tip**: Using ES2015 array deconstructuring makes this a breeze.* 167 | 168 | ```javascript 169 | var html = require('nanohtml') 170 | var component = require('fun-component') 171 | 172 | var greeting = component(function greeting (ctx, name) { 173 | return html`

Hello ${name}!

` 174 | }) 175 | 176 | // deconstruct arguments and compare `name` 177 | greeting.on('update', function (ctx, [name], [prev]) { 178 | return name !== prev 179 | }) 180 | ``` 181 | 182 | ### Plugins 183 | 184 | Plugins are middleware functions that are called just before the component is rendered or updated. A plugin can inspect the arguments, modify the context object or even return another context object that is to be used for rendering the component. 185 | 186 | ```javascript 187 | const html = require('nanohtml') 188 | const component = require('fun-component') 189 | 190 | const greeter = component(function greeting (ctx, title) { 191 | return html`

Hello ${title}!

` 192 | }) 193 | 194 | greeter.use(function log (ctx, title) { 195 | console.log(`Rendering ${ctx._name} with ${title}`) 196 | return ctx 197 | }) 198 | 199 | document.body.appendChild(greeter('world')) 200 | ``` 201 | 202 | fun-component is bundled with with a handfull of plugins that cover the most common scenarios. Have you written a plugin you want featured in this list? Fork, add, and make a pull request. 203 | 204 | - [spawn](spawn) – Spawn component contexts on demand and discard on unload. 205 | - [restate](restate) – Add state object and state management to the context object. 206 | - [logger](logger) – Add a logger (using [nanologger](https://github.com/choojs/nanologger)) to the context object. 207 | - [cache](cache) – Cache element and reuse on consecutive mounts. 208 | 209 | ## Examples 210 | 211 | For example implementations, see [/examples](/examples). Either spin them up locally or visit the link. 212 | 213 | - Mapbox (using [cache](cache)) 214 | - `npm run example:mapbox` 215 | - https://fun-component-mapbox.now.sh 216 | - List (using [spawn](spawn)) 217 | - `npm run example:list` 218 | - https://fun-component-list.now.sh 219 | - Expandable (using [spawn](spawn) and [restate](restate)) 220 | - `npm run example:expandable` 221 | - https://fun-component-expandable.now.sh 222 | 223 | ### Composition and forking 224 | 225 | Using lifecycle event listeners and plugins makes it very easy to lazily compose functions by attaching and removing event listeners as needed. But you may sometimes wish to scope some listeners or plugins to a specific use case. To create a new component instance, inheriting all plugins and listeners, you may `fork` a component. 226 | 227 | ```javascript 228 | // button.js 229 | var html = require('nanohtml') 230 | var component = require('fun-component') 231 | 232 | var button = module.exports = component(function button (ctx, text, onclick) { 233 | return html`` 234 | }) 235 | 236 | // only bother with updating the text 237 | button.on('update', function (ctx, [text], [prev]) { 238 | return text !== prev 239 | }) 240 | ``` 241 | 242 | ```javascript 243 | // infinite-tweets.js 244 | var html = require('nanohtml') 245 | var component = require('fun-component') 246 | var onIntersect = require('on-intersect') 247 | var button = require('./button') 248 | 249 | module.exports = list 250 | 251 | // fork button so that we can add custom behavior 252 | var paginator = button.fork() 253 | 254 | // automatically click button when in view 255 | paginator.on('load', function (ctx, el, text, onclick) { 256 | var disconnect = onIntersect(el, onclick) 257 | paginator.on('unload', disconnect) 258 | }) 259 | 260 | function list (tweets, paginate) { 261 | return html` 262 |
263 | 274 | ${paginator('Show more', paginate)} 275 |
276 | ` 277 | } 278 | ``` 279 | 280 | ## Why tho? 281 | 282 | Authoring a component should be as easy as writing a function. Using arguments and scope to handle a components lifecycle is obvious and straight forward. Whereas having to worry about calling context and stashing things on `this` makes for cognitive overhead. 283 | 284 | Not for you? If you need more fine grained control or perfer a straight up object oriented programming approach, try using [nanocomponent](https://github.com/choojs/nanocomponent), it's what's powering fun-component behind the scenes. 285 | 286 | ## See Also 287 | 288 | - [yoshuawuyts/microcomponent](https://github.com/yoshuawuyts/microcomponent) 289 | - [jongacnik/component-box](https://github.com/jongacnik/component-box) 290 | - [choojs/nanocomponent](https://github.com/choojs/nanocomponent) 291 | - [choojs/nanohtml](https://github.com/choojs/nanohtml) 292 | - [choojs/choo](https://github.com/choojs/choo) 293 | 294 | ## License 295 | 296 | [MIT](https://tldrlegal.com/license/mit-license) 297 | -------------------------------------------------------------------------------- /cache/README.md: -------------------------------------------------------------------------------- 1 | # fun-component/cache 2 | 3 | Cache component element for reuse. 4 | 5 | ## Usage 6 | 7 | When working with 3rd party libraries you might *not* want the element to rerender every time it is removed and added back to the page. This examples illustrates caching a Mapbox container element. 8 | 9 | ```javascript 10 | const html = require('nanohtml') 11 | const component = require('fun-component') 12 | const cache = require('fun-component/cache') 13 | 14 | const map = component(function map (ctx, coordinates) { 15 | return html`
` 16 | }) 17 | 18 | // register cache middleware 19 | map.use(cache()) 20 | 21 | map.on('load', function (ctx, el, coordinates) { 22 | if (ctx.map) { 23 | // recenter existing map 24 | ctx.map.setCenter([coordinates.lng, coordinates.lat]) 25 | } else { 26 | // initialize new map 27 | ctx.map = new mapboxgl.Map({ 28 | container: el, 29 | center: [coordinates.lng, coordinates.lat], 30 | }) 31 | } 32 | }) 33 | 34 | map.on('update', function (ctx, [coordinates], [prev]) { 35 | if (coordinates.lng !== prev.lng || coordinates.lat !== prev.lat) { 36 | ctx.map.setCenter([coordinates.lng, coordinates.lat]) 37 | } 38 | return false 39 | }) 40 | ``` 41 | 42 | ## API 43 | 44 | ### `cache()` 45 | 46 | Create middleware that saves a reference to mounted element as `ctx.cached`. Use this property to check whether a new element is being mounted or if using the cache. 47 | 48 | ## Clearing cached elements 49 | 50 | Unset `ctx.cached` to have a element be re-created on next render. 51 | 52 | ```javascript 53 | const html = require('nanohtml') 54 | const component = require('fun-component') 55 | const cache = require('fun-component/cache') 56 | 57 | const render = component(function uncached (ctx) { 58 | return html`
` 59 | }) 60 | 61 | // register cache middleware 62 | render.use(cache()) 63 | 64 | render.on('unload', function (ctx) { 65 | // unset cache to create a new element on next render 66 | if (someCondition) delete ctx.cached 67 | }) 68 | ``` 69 | -------------------------------------------------------------------------------- /cache/index.js: -------------------------------------------------------------------------------- 1 | // cache element and reuse on consecutive mounts 2 | // () -> fn 3 | module.exports = function init () { 4 | var initialized = false 5 | 6 | return function cache (ctx) { 7 | if (!initialized) { 8 | initialized = true 9 | 10 | ctx.on('beforerender', function (ctx, element) { 11 | ctx.cached = element 12 | }) 13 | 14 | // proxy render method returning cached element when applicable 15 | var render = ctx.render 16 | ctx.render = function () { 17 | if (!ctx.element && ctx.cached) return ctx.cached 18 | return render.apply(ctx, Array.prototype.slice.call(arguments)) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/expandable/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | html { 8 | height: 100%; 9 | } 10 | 11 | .App { 12 | display: flex; 13 | flex-direction: column; 14 | height: 100%; 15 | padding: 0 5vw; 16 | color: #222; 17 | font-size: 16px; 18 | font-family: monospace; 19 | } 20 | 21 | .App-container { 22 | flex-grow: 1; 23 | } 24 | 25 | .Button { 26 | flex: 0 0 auto; 27 | display: block; 28 | width: 100%; 29 | padding: 12px 24px; 30 | border: 3px solid #222; 31 | margin: 16px 0; 32 | color: #fff; 33 | font-size: 16px; 34 | font-family: inherit; 35 | background: #222; 36 | appearence: none; 37 | border-radius: 0; 38 | } 39 | 40 | .Button--invert { 41 | border-color: #222; 42 | background-color: #fff; 43 | color: #222; 44 | } 45 | 46 | .Button--inline { 47 | flex: 0 0 auto; 48 | display: inline-block; 49 | width: auto; 50 | } 51 | 52 | .Header { 53 | display: flex; 54 | align-items: center; 55 | justify-content: space-between; 56 | margin-top: 40px; 57 | } 58 | 59 | .Header-title { 60 | font-size: 36px; 61 | } 62 | 63 | .Text { 64 | display: block; 65 | width: 100%; 66 | padding: 16px; 67 | border: 3px solid currentColor; 68 | resize: vertical; 69 | font-family: monospace; 70 | font-size: 16px; 71 | } 72 | 73 | .Output { 74 | margin: 24px 0; 75 | } 76 | 77 | .Output h1:not(:first-child), 78 | .Output h2:not(:first-child), 79 | .Output h3:not(:first-child), 80 | .Output h4:not(:first-child), 81 | .Output h5:not(:first-child), 82 | .Output h6:not(:first-child), 83 | .Output ol:not(:first-child), 84 | .Output ul:not(:first-child), 85 | .Output hr:not(:first-child), 86 | .Output p:not(:first-child) { 87 | margin: 1.5em 0; 88 | } 89 | 90 | .Output hr { 91 | border-top: 3px dashed currentColor; 92 | background: transparent; 93 | } 94 | -------------------------------------------------------------------------------- /examples/expandable/index.js: -------------------------------------------------------------------------------- 1 | const html = require('nanohtml') 2 | const morph = require('nanomorph') 3 | const raw = require('nanohtml/raw') 4 | const MarkdownIt = require('markdown-it') 5 | const component = require('fun-component') 6 | const restate = require('fun-component/restate') 7 | const spawn = require('fun-component/spawn') 8 | 9 | const parser = new MarkdownIt() 10 | 11 | const DEFAULT_TEXT = ` 12 | # Stateful Example – fun-component 13 | 14 | This is an example illustrating how to work with multiple stateful components using [fun-component](https://github.com/tornqvist/fun-component), a performant component encapsulated as a function. 15 | `.trim() 16 | 17 | const expandable = component(function expandable (ctx, id, text) { 18 | const toggle = () => ctx.restate({expanded: !ctx.state.expanded}) 19 | 20 | return html` 21 |
22 | 25 |
26 | ${raw(parser.render(text))} 27 |
28 |
29 | ` 30 | }) 31 | 32 | // use first argument as key for context 33 | expandable.use(spawn((id) => id)) 34 | 35 | // default expandables to be collapsed 36 | expandable.use(restate({expanded: false})) 37 | 38 | // create a base textarea component 39 | const input = component(function input (ctx, id, text, oninput) { 40 | const textarea = html`` 41 | textarea.value = text // Needed to preserve linebreaks 42 | return textarea 43 | }) 44 | 45 | // use first argument as key for context 46 | input.use(spawn((id) => id)) 47 | 48 | const state = {'welcome-01': DEFAULT_TEXT} 49 | morph(document.body, view(state)) 50 | 51 | // rerender application 52 | // obj -> void 53 | function update (next) { 54 | morph(document.body, view(Object.assign(state, next))) 55 | } 56 | 57 | // main view 58 | // obj -> HTMLElement 59 | function view (state) { 60 | return html` 61 | 62 |
63 | ${Object.keys(state).map(id => html` 64 |
65 |
66 |

File #${id}

67 | 68 |
69 | ${input(id, state[id] || '', oninput(id))} 70 | ${expandable(id, state[id] || 'Nothing here')} 71 |
72 | `)} 73 |
74 | 75 | 76 | ` 77 | 78 | // remove file 79 | // str -> fn 80 | function remove (id) { 81 | return function () { 82 | delete state[id] 83 | update() 84 | } 85 | } 86 | 87 | // add file 88 | // 89 -> void 89 | function add () { 90 | update({ [makeID()]: '' }) 91 | } 92 | 93 | // handle user unput 94 | // str -> fn 95 | function oninput (id) { 96 | return function (event) { 97 | const value = event.target.value 98 | update({ [id]: value }) 99 | } 100 | } 101 | } 102 | 103 | // generate unique id 104 | // () -> str 105 | function makeID () { 106 | return 'id-' + Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) 107 | } 108 | -------------------------------------------------------------------------------- /examples/expandable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fun-component-expandable", 3 | "scripts": { 4 | "start": "budo index.js --title 'Expandable Example | fun-component' --css index.css" 5 | }, 6 | "browserify": { 7 | "development": [ 8 | [ 9 | "aliasify", 10 | { 11 | "aliases": { 12 | "fun-component": "../.." 13 | } 14 | } 15 | ] 16 | ] 17 | }, 18 | "devDependencies": { 19 | "aliasify": "*" 20 | }, 21 | "dependencies": { 22 | "budo": "*", 23 | "fun-component": "*", 24 | "markdown-it": "*", 25 | "nanohtml": "*", 26 | "nanomorph": "*" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/list/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | html { 8 | height: 100%; 9 | } 10 | 11 | .App { 12 | height: 100%; 13 | padding: 0 5vw; 14 | color: #222; 15 | font-size: 16px; 16 | font-family: monospace; 17 | } 18 | 19 | .List { 20 | width: 100%; 21 | max-width: 600px; 22 | padding: 2rem 0; 23 | margin: 0 auto; 24 | position: relative; 25 | } 26 | 27 | .List-item { 28 | line-height: 2em; 29 | text-align: center; 30 | } 31 | 32 | .List-item.in-transition { 33 | transition: transform 800ms ease-in-out; 34 | } 35 | 36 | .Button { 37 | width: 100%; 38 | margin-bottom: 1rem; 39 | padding: 0.5em 2.5em; 40 | border: 2px solid #222; 41 | 42 | position: relative; 43 | 44 | color: #222; 45 | font-size: 16px; 46 | outline: none; 47 | font-family: inherit; 48 | text-transform: uppercase; 49 | background: transparent; 50 | border-radius: 0; 51 | 52 | transition: padding-right 200ms linear; 53 | } 54 | 55 | .Button::after { 56 | content: ""; 57 | width: 0; 58 | height: 0; 59 | border: 0.35em solid transparent; 60 | 61 | position: absolute; 62 | top: 50%; 63 | right: 0.75em; 64 | 65 | transform: translateY(-25%); 66 | transition: transform 200ms linear; 67 | } 68 | 69 | .Button.is-active::after { 70 | border-color: #fff transparent transparent; 71 | } 72 | 73 | .Button.is-active.is-reversed::after { 74 | transform: translateY(-75%) rotate(180deg); 75 | } 76 | 77 | .Button.is-active { 78 | color: #fff; 79 | background-color: #222; 80 | } 81 | 82 | .Button:disabled { 83 | opacity: 0.6; 84 | } 85 | 86 | .Text { 87 | max-width: 600px; 88 | margin: 2.5rem auto 1rem; 89 | position: relative; 90 | line-height: 1.4; 91 | } 92 | 93 | .Text * { 94 | margin: 1em 0; 95 | } 96 | 97 | .Text h1 { 98 | text-align: center; 99 | } 100 | 101 | .Text a { 102 | white-space: nowrap; 103 | } 104 | -------------------------------------------------------------------------------- /examples/list/index.js: -------------------------------------------------------------------------------- 1 | const html = require('nanohtml') 2 | const morph = require('nanomorph') 3 | const component = require('fun-component') 4 | const spawn = require('fun-component/spawn') 5 | const { elements } = require('periodic-table') 6 | 7 | const row = component(function element (ctx, props) { 8 | return html` 9 | 10 | ${props.atomicNumber} 11 | ${props.name} (${props.symbol}) 12 | ${props.yearDiscovered} 13 | 14 | ` 15 | }) 16 | 17 | row.on('load', function (ctx, element) { 18 | // stash initial offset on ctx 19 | ctx.offset = element.offsetTop 20 | }) 21 | 22 | row.on('afterupdate', function (ctx, element, props, index, done) { 23 | if (!ctx.offset) return 24 | window.requestAnimationFrame(function () { 25 | const offset = element.offsetTop 26 | 27 | // put element back at previous offset 28 | element.style.transform = `translateY(${ctx.offset - offset}px)` 29 | 30 | window.requestAnimationFrame(function () { 31 | element.addEventListener('transitionend', function ontransitionend () { 32 | element.removeEventListener('transitionend', ontransitionend) 33 | element.classList.remove('in-transition') 34 | ctx.offset = offset 35 | done() 36 | }) 37 | 38 | // trigger transition 39 | element.classList.add('in-transition') 40 | element.style.removeProperty('transform') 41 | }) 42 | }) 43 | }) 44 | 45 | // use atomic number as key for component context 46 | row.use(spawn((props) => props.atomicNumber.toString())) 47 | 48 | // mount application on document body 49 | morph(document.body, view()) 50 | 51 | // main view 52 | // (fn, bool, bool) -> HTMLElement 53 | function view (order = byNumber, reverse = false, inTransition = false) { 54 | // create a new list of sorted rows 55 | const items = Object.values(elements).sort(order) 56 | if (reverse) items.reverse() 57 | 58 | return html` 59 | 60 |
61 |

List Example – fun-component

62 |

This is an example illustrating list reordering using fun-component, a performant component encapsulated as a function.

63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ${items.map((props, index) => row(props, index, done))} 74 | 75 |
76 | 77 | ` 78 | 79 | // rerender application using given sort function 80 | // fn -> fn 81 | function sort (next) { 82 | return function () { 83 | morph(document.body, view(next, (next === order && !reverse), true)) 84 | } 85 | } 86 | 87 | // rerender application with active buttons 88 | // () -> void 89 | function done () { 90 | if (!inTransition) return 91 | inTransition = false 92 | morph(document.body, view(order, reverse, inTransition)) 93 | } 94 | } 95 | 96 | // sort by atomic number 97 | // (obj, obj) -> num 98 | function byNumber (a, b) { 99 | return a.atomicNumber > b.atomicNumber ? 1 : -1 100 | } 101 | 102 | // sort by atom name 103 | // (obj, obj) -> num 104 | function byName (a, b) { 105 | return a.name > b.name ? 1 : -1 106 | } 107 | 108 | // sort by date discovered 109 | // (obj, obj) -> num 110 | function byDate (a, b) { 111 | if (a.yearDiscovered === 'Ancient') return -1 112 | else if (b.yearDiscovered === 'Ancient') return 1 113 | return a.yearDiscovered > b.yearDiscovered ? 1 : -1 114 | } 115 | -------------------------------------------------------------------------------- /examples/list/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fun-component-list", 3 | "scripts": { 4 | "start": "budo index.js --title 'List Example | fun-component' --css index.css" 5 | }, 6 | "browserify": { 7 | "development": [ 8 | [ 9 | "aliasify", 10 | { 11 | "aliases": { 12 | "fun-component": "../.." 13 | } 14 | } 15 | ] 16 | ] 17 | }, 18 | "devDependencies": { 19 | "aliasify": "*" 20 | }, 21 | "dependencies": { 22 | "budo": "*", 23 | "fun-component": "*", 24 | "nanohtml": "*", 25 | "nanomorph": "*", 26 | "periodic-table": "0.0.8" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/mapbox/index.css: -------------------------------------------------------------------------------- 1 | @import "https://api.mapbox.com/mapbox-gl-js/v0.39.1/mapbox-gl.css"; 2 | 3 | * { 4 | box-sizing: border-box; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | html { 10 | height: 100%; 11 | } 12 | 13 | .App { 14 | display: flex; 15 | flex-direction: column; 16 | height: 100%; 17 | align-items: stretch; 18 | font-family: monospace; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | .Map { 24 | flex-grow: 1; 25 | position: relative; 26 | } 27 | 28 | .Map-container { 29 | width: 100%; 30 | height: 100%; 31 | position: absolute; 32 | left: 0; 33 | top: 0; 34 | font-family: inherit; 35 | } 36 | 37 | .Button { 38 | display: block; 39 | padding: 12px; 40 | border: 3px solid currentColor; 41 | 42 | position: absolute; 43 | right: 2.5vw; 44 | top: 2.5vw; 45 | 46 | color: #222; 47 | text-transform: uppercase; 48 | font-size: 22px; 49 | font-weight: bold; 50 | font-family: inherit; 51 | background: #fff; 52 | border-radius: 0; 53 | } 54 | 55 | .Button:disabled { 56 | opacity: 0.6; 57 | } 58 | 59 | .Error { 60 | padding: 20px; 61 | text-align: center; 62 | font-weight: bold; 63 | color: #fff; 64 | background: #F02E2E; 65 | } 66 | 67 | .Error:empty { 68 | padding: 0; 69 | } 70 | 71 | .Menu { 72 | padding: 20px 20px 15px; 73 | z-index: 1; 74 | text-align: center; 75 | box-shadow: 0 2px 20px rgba(0,0,0,0.1); 76 | } 77 | 78 | .Menu-item, 79 | .Menu-item:visited { 80 | display: inline-block; 81 | margin: 0 10px; 82 | font-weight: bold; 83 | font-size: 20px; 84 | text-transform: uppercase; 85 | text-decoration: none; 86 | color: blue; 87 | } 88 | 89 | .Menu-item:hover { 90 | text-decoration: underline; 91 | } 92 | 93 | .Menu-item.is-active { 94 | color: #222; 95 | text-decoration: underline; 96 | } 97 | 98 | .Text { 99 | max-width: 45em; 100 | padding: 0 5vw; 101 | margin: 0 auto; 102 | position: relative; 103 | line-height: 1.4; 104 | } 105 | 106 | .Text * { 107 | margin: 1em 0; 108 | } 109 | 110 | .Text h1 { 111 | text-align: center; 112 | } 113 | 114 | .Text a { 115 | color: blue; 116 | } 117 | 118 | .Text a:visited { 119 | color: purple; 120 | } 121 | 122 | .mapboxgl-popup-content { 123 | padding: 12px 16px; 124 | color: #fff; 125 | font-size: 16px; 126 | background-color: #222; 127 | } 128 | 129 | .mapboxgl-popup-anchor-top .mapboxgl-popup-tip { border-bottom-color: #222; } 130 | 131 | .mapboxgl-popup-anchor-right .mapboxgl-popup-tip { border-left-color: #222; } 132 | 133 | .mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { border-top-color: #222; } 134 | 135 | .mapboxgl-popup-anchor-left .mapboxgl-popup-tip { border-right-color: #222; } 136 | -------------------------------------------------------------------------------- /examples/mapbox/index.js: -------------------------------------------------------------------------------- 1 | /* globals mapboxgl */ 2 | 3 | const html = require('nanohtml') 4 | const morph = require('nanomorph') 5 | const component = require('fun-component') 6 | const cache = require('fun-component/cache') 7 | 8 | const MAPBOX_TOKEN = 'pk.eyJ1IjoidG9ybnF2aXN0IiwiYSI6ImNqN2RjZHpmbTA1cjIzM3BmaGpkZnQxNHEifQ.ZSG3Gi0X-8Fane8_u9LdeQ' 9 | const MAPBOX_URL = 'https://api.mapbox.com/mapbox-gl-js/v0.39.1/mapbox-gl.js' 10 | const INITIAL_STATE = { 11 | lng: 18.0704503, 12 | lat: 59.3244897, 13 | error: null, 14 | positioned: false, 15 | isLoading: false, 16 | href: window.location.pathname 17 | } 18 | 19 | const mapbox = component(function mapbox () { 20 | return html`
` 21 | }) 22 | 23 | mapbox.on('update', function update (ctx, args, prev) { 24 | if (!ctx.map) return false 25 | 26 | // shallow diff of arguments to see if anything has changed 27 | if (args.reduce((changed, arg, i) => changed || arg !== prev[i], false)) { 28 | const [ lng, lat, positioned ] = args 29 | 30 | ctx.map.panTo([lng, lat]) 31 | 32 | if (positioned) { 33 | ctx.popup 34 | .setLngLat([lng, lat]) 35 | .setText('You are here') 36 | .addTo(ctx.map) 37 | } else if (ctx.popup.isOpen()) { 38 | ctx.popup.remove() 39 | } 40 | } 41 | 42 | return false 43 | }) 44 | 45 | mapbox.on('load', function (ctx, element, lng, lat, positioned, loading) { 46 | if (ctx.map) { 47 | // resize map when being re-mounted 48 | ctx.map.resize() 49 | } else { 50 | // load mapbox library 51 | const script = html`` 52 | 53 | loading(true) 54 | script.onload = () => { 55 | mapboxgl.accessToken = MAPBOX_TOKEN 56 | ctx.map = new mapboxgl.Map({ 57 | container: ctx.element, 58 | center: [lng, lat], 59 | zoom: 11, 60 | style: 'mapbox://styles/tornqvist/cj8zu6vbvc0i62rn6oxb7gfyb' 61 | }) 62 | 63 | // initialize a popup and stash on ctx 64 | ctx.popup = new mapboxgl.Popup({ 65 | closeOnClick: false, 66 | closeButton: false 67 | }) 68 | 69 | // callback once mapbox has finshed loaded 70 | ctx.map.on('load', () => loading(false)) 71 | } 72 | document.head.appendChild(script) 73 | } 74 | }) 75 | 76 | mapbox.on('unload', function (ctx) { 77 | if (ctx.popup.isOpen()) { 78 | // remove the popup when unmounting the map 79 | ctx.popup.remove() 80 | } 81 | }) 82 | 83 | // cache map container element 84 | mapbox.use(cache()) 85 | 86 | const about = component(function page () { 87 | return html` 88 |
89 |

Mapbox Example – fun-component

90 |

This page is for illustrating that if you go back to the map, it has been cached and does not need to initialize the Mapbox instance again upon mounting in the DOM.

91 |

This is an example implementation of Mapbox using fun-component, a performant component encapsulated as a function.

92 |
93 | ` 94 | }) 95 | 96 | // handle browser history 97 | window.history.replaceState(INITIAL_STATE, document.title, window.location.pathname) 98 | window.onpopstate = event => morph(document.body, view(event.state)) 99 | 100 | // mount app in DOM 101 | morph(document.body, view(INITIAL_STATE)) 102 | 103 | // main view 104 | // obj -> HTMLElement 105 | function view (state = {}) { 106 | return html` 107 | 108 |
${state.error}
109 | 113 | ${state.href === '/' ? html` 114 |
115 | ${mapbox(state.lng, state.lat, state.positioned, loading)} 116 | 117 |
118 | ` : about()} 119 | 120 | ` 121 | 122 | // toggle loading state of application 123 | // bool -> void 124 | function loading (isLoading) { 125 | morph(document.body, view(Object.assign({}, state, {isLoading}))) 126 | } 127 | 128 | // handle navigating between views 129 | // obj -> void 130 | function navigate (event) { 131 | const href = event.target.pathname 132 | const next = Object.assign({}, state, {href}) 133 | window.history.pushState(next, document.title, href) 134 | morph(document.body, view(next)) 135 | event.preventDefault() 136 | } 137 | 138 | // find user location 139 | // () -> void 140 | function locate () { 141 | morph(document.body, view(Object.assign({}, state, {isLoading: true}))) 142 | navigator.geolocation.getCurrentPosition( 143 | (position) => morph(document.body, view(Object.assign({}, state, { 144 | lat: position.coords.latitude, 145 | lng: position.coords.longitude, 146 | error: null, 147 | positioned: true 148 | }))), 149 | err => morph(document.body, view(Object.assign({}, state, { 150 | error: err.message, 151 | positioned: false 152 | }))) 153 | ) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /examples/mapbox/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fun-component-mapbox", 3 | "scripts": { 4 | "start": "budo index.js --pushstate --title 'Mapbox Example | fun-component' --css index.css" 5 | }, 6 | "browserify": { 7 | "development": [ 8 | [ 9 | "aliasify", 10 | { 11 | "aliases": { 12 | "fun-component": "../.." 13 | } 14 | } 15 | ] 16 | ] 17 | }, 18 | "devDependencies": { 19 | "aliasify": "*" 20 | }, 21 | "dependencies": { 22 | "budo": "*", 23 | "fun-component": "*", 24 | "nanohtml": "*", 25 | "nanomorph": "*" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var assert = require('nanoassert') 2 | var Nanocomponent = require('nanocomponent') 3 | 4 | var NAME = 'fun-component' 5 | 6 | module.exports = component 7 | module.exports.Context = Context 8 | 9 | // create a function that proxies nanocomponent 10 | // (str, fn) -> fn 11 | function component (name, render) { 12 | if (typeof name === 'function') { 13 | render = name 14 | name = render.name || NAME 15 | } 16 | 17 | var middleware = [] 18 | var context = new Context(name, render) 19 | 20 | function renderer () { 21 | var ctx = context 22 | var args = Array.prototype.slice.call(arguments) 23 | var forward = [ctx] 24 | forward.push.apply(forward, args) 25 | for (var i = 0, len = middleware.length, next; i < len; i++) { 26 | next = middleware[i].apply(undefined, forward) 27 | if (next && next !== ctx) { 28 | assert(typeof ctx.render === 'function', 'fun-component: plugin should return a component context') 29 | ctx = next 30 | forward.splice(0, 1, next) 31 | } 32 | } 33 | return ctx.render.apply(ctx, args) 34 | } 35 | 36 | Object.defineProperties(renderer, { 37 | use: { 38 | get: function () { return use } 39 | }, 40 | on: { 41 | get: function () { return context.on.bind(context) } 42 | }, 43 | off: { 44 | get: function () { return context.off.bind(context) } 45 | }, 46 | fork: { 47 | get: function () { return fork } 48 | } 49 | }) 50 | 51 | Object.defineProperty(renderer, 'name', { 52 | value: name, 53 | writable: false, 54 | enumerable: false, 55 | configurable: true 56 | }) 57 | 58 | // add plugin middleware 59 | // fn -> void 60 | function use (fn) { 61 | middleware.push(fn) 62 | } 63 | 64 | // fork a component inheriting all event listeners and middleware 65 | // str? -> fn 66 | function fork (_name) { 67 | var forked = component(_name || name, render) 68 | var events = Object.keys(context._events) 69 | for (var e = 0, elen = events.length, listeners, l, llen; e < elen; e++) { 70 | listeners = context._events[events[e]] 71 | for (l = 0, llen = listeners.length; l < llen; l++) { 72 | forked.on(events[e], listeners[l]) 73 | } 74 | } 75 | for (var m = 0, mlen = middleware.length; m < mlen; m++) { 76 | forked.use(middleware[m]) 77 | } 78 | return forked 79 | } 80 | 81 | return renderer 82 | } 83 | 84 | // custom extension of nanocomponent 85 | // (str, fn) -> Context 86 | function Context (name, render) { 87 | assert(typeof name === 'string', 'fun-component: name should be a string') 88 | assert(typeof render === 'function', 'fun-component: render should be a function') 89 | Nanocomponent.call(this, name) 90 | var ctx = this 91 | this._events = {} 92 | this._render = render 93 | this.createElement = function () { 94 | var args = Array.prototype.slice.call(arguments) 95 | args.unshift(ctx) 96 | return render.apply(undefined, args) 97 | } 98 | } 99 | 100 | Context.prototype = Object.create(Nanocomponent.prototype) 101 | Context.prototype.contructor = Context 102 | 103 | Context.prototype.update = update 104 | 105 | // add lifecycle event listener 106 | // (str, fn) -> void 107 | Context.prototype.on = function (event, listener) { 108 | assert(typeof event === 'string', 'fun-component: event should be a string') 109 | assert(typeof listener === 'function', 'fun-component: listener should be a function') 110 | 111 | var events = this._events[event] 112 | if (!events) events = this._events[event] = [] 113 | events.push(listener) 114 | 115 | if (!this[event] || (event === 'update' && this.update === update)) { 116 | this[event] = function () { 117 | var result 118 | var args = Array.prototype.slice.call(arguments) 119 | var events = this._events[event] 120 | 121 | if (event === 'update') { 122 | // compose `update` arguments for diffing 123 | args = [this, args, this._arguments] 124 | } else { 125 | args.unshift(this) 126 | args.push.apply(args, this._arguments) 127 | } 128 | 129 | // run through all events listeners in order, aggregating return value 130 | for (var i = 0, len = events.length, next; i < len; i++) { 131 | next = events[i].apply(undefined, args) 132 | if (event === 'update' && i > 0) result = result || next 133 | else result = next 134 | } 135 | if (event === 'update') return result 136 | } 137 | } 138 | } 139 | 140 | // remove lifecycle event listener 141 | // (str, fn) -> void 142 | Context.prototype.off = function (event, listener) { 143 | assert(typeof event === 'string', 'fun-component: event should be a string') 144 | assert(typeof listener === 'function', 'fun-component: listener should be a function') 145 | 146 | var events = this._events[event] 147 | if (!events) return 148 | 149 | var index = events.indexOf(listener) 150 | if (index === -1) return 151 | 152 | events.splice(index, 1) 153 | 154 | // remove depleeted listener proxy method 155 | if (!events.length) delete this[event] 156 | } 157 | 158 | // simple shallow diff of two sets of arguments 159 | // (arr, arr) -> bool 160 | function update () { 161 | var result = false 162 | var args = Array.prototype.slice.call(arguments) 163 | var prev = this._arguments 164 | 165 | // different lengths issues rerender 166 | if (args.length !== this._arguments.length) return true 167 | 168 | // make best effort to compare element as argument, fallback to shallow diff 169 | for (var i = 0, len = args.length, arg; i < len; i++) { 170 | arg = args[i] 171 | if (arg && arg.isSameNode) result = result || !arg.isSameNode(prev[i]) 172 | else result = result || arg !== prev[i] 173 | } 174 | 175 | return result 176 | } 177 | -------------------------------------------------------------------------------- /logger/README.md: -------------------------------------------------------------------------------- 1 | # fun-component/logger 2 | 3 | Log all lifecycle events. Using [nanologger](https://github.com/choojs/nanologger). 4 | 5 | ## Usage 6 | 7 | Access the nanologger instance under `ctx.log`. 8 | 9 | ```javascript 10 | const html = require('nanohtml') 11 | const component = require('fun-component') 12 | 13 | var hello = component(function hello (ctx, title) { 14 | return html` 15 |
16 | Hello ${title}! 17 |
18 | ` 19 | }) 20 | 21 | // enable logging during development 22 | if (process.env.NODE_ENV === 'development') { 23 | hello.use(require('fun-component/logger')()) 24 | } 25 | ``` 26 | 27 | ## API 28 | 29 | ### `logger([opts])` 30 | 31 | Create a middleware that adds an instance of nanologger to the `ctx.log` property. The opts are forwarded to [nanologger](https://github.com/choojs/nanologger). 32 | -------------------------------------------------------------------------------- /logger/index.js: -------------------------------------------------------------------------------- 1 | var Nanologger = require('nanologger') 2 | 3 | var EVENTS = ['load', 'unload', 'beforerender', 'afterupdate', 'afterreorder'] 4 | 5 | // add logger to context and log all lifycycle event 6 | // obj -> fn 7 | module.exports = function init (options) { 8 | return function logger (ctx) { 9 | if (!ctx.log) { 10 | ctx.log = new Nanologger(ctx._name, options) 11 | 12 | for (var i = 0, len = EVENTS.length; i < len; i++) { 13 | ctx.on(EVENTS[i], createListener(EVENTS[i])) 14 | } 15 | 16 | // proxy render capturing lifecycle events 17 | var render = ctx.render 18 | ctx.render = function () { 19 | var args = Array.prototype.slice.call(arguments) 20 | if (ctx.element) ctx.log.debug('update', args) 21 | var element = render.call(ctx, args) 22 | if (!ctx.element) ctx.log.debug('render', args) 23 | return element 24 | } 25 | } 26 | 27 | function createListener (event) { 28 | return function () { 29 | ctx.log.debug(event, Array.prototype.slice.call(arguments, 1)) 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fun-component", 3 | "version": "4.1.0", 4 | "description": "Functional approach to authoring performant HTML components using plugins", 5 | "main": "index.js", 6 | "scripts": { 7 | "posttest": "standard", 8 | "test": "npm run test:node && npm run test:browser", 9 | "test:browser": "browserify test/browser.js | tape-run | tap-format-spec", 10 | "test:node": "NODE_ENV=test node test/node.js | tap-format-spec", 11 | "example:mapbox": "npm install --prefix ./examples/mapbox && npm start --prefix ./examples/mapbox -- -- --transform-key=development", 12 | "example:list": "npm install --prefix ./examples/list && npm start --prefix ./examples/list -- -- --transform-key=development", 13 | "example:expandable": "npm install --prefix ./examples/expandable && npm start --prefix ./examples/expandable -- -- --transform-key=development" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/tornqvist/fun-component.git" 18 | }, 19 | "keywords": [ 20 | "choo", 21 | "nanohtml", 22 | "component", 23 | "html", 24 | "function" 25 | ], 26 | "author": "Carl Törnqvist ", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/tornqvist/fun-component/issues" 30 | }, 31 | "homepage": "https://github.com/tornqvist/fun-component#readme", 32 | "devDependencies": { 33 | "@tap-format/spec": "^0.2.0", 34 | "browserify": "^15.2.0", 35 | "nanohtml": "^1.2.1", 36 | "standard": "^11.0.0", 37 | "tape": "^4.8.0", 38 | "tape-run": "^3.0.0" 39 | }, 40 | "dependencies": { 41 | "nanoassert": "^1.1.0", 42 | "nanocomponent": "^6.4.2", 43 | "nanologger": "^1.3.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /restate/README.md: -------------------------------------------------------------------------------- 1 | # fun-component/restate 2 | 3 | Simple state manager that handles rerendering. Much like React `setState`. 4 | 5 | ## Usage 6 | 7 | ```javascript 8 | const html = require('nanohtml') 9 | const component = require('fun-component') 10 | const restate = require('fun-component/restate') 11 | 12 | const expandable = component(function expandable (ctx, text) { 13 | const toggle = () => ctx.restate({ expanded: !ctx.state.expanded }) 14 | 15 | return html` 16 |
17 | 18 |

19 | ${text} 20 |

21 |
22 | ` 23 | }) 24 | 25 | // set initial state of exandables to be collapsed 26 | expandable.use(restate({expanded: false})) 27 | 28 | document.body.appendChild(expandable('Hi there!')) 29 | ``` 30 | 31 | ## API 32 | 33 | ### `restate(initialState)` 34 | 35 | Create a middleware that adds a `state` object and the `restate` method to context. 36 | 37 | ### `ctx.restate(nextState)` 38 | 39 | Takes a new state as only argument. It updates the state and issues a rerender with the latest arguments. 40 | -------------------------------------------------------------------------------- /restate/index.js: -------------------------------------------------------------------------------- 1 | var assert = require('nanoassert') 2 | 3 | // add state object and state management to the context object 4 | // obj -> fn 5 | module.exports = function init (initialState) { 6 | assert(typeof initialState === 'object', 'fun-component: initialState should be an object') 7 | 8 | return function restate (ctx) { 9 | if (!ctx.state) { 10 | ctx.state = Object.assign({}, initialState) 11 | } 12 | 13 | if (!ctx.restate) { 14 | // proxy rerender with state manager 15 | ctx.restate = function restate (next) { 16 | assert(typeof next === 'object', 'fun-component: state should be an object') 17 | Object.assign(ctx.state, next) 18 | return ctx.rerender() 19 | } 20 | } 21 | 22 | return ctx 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /spawn/README.md: -------------------------------------------------------------------------------- 1 | # fun-component/spawn 2 | 3 | Create new component contexts on demand, optionally discarding them on unload. 4 | 5 | ## Usage 6 | 7 | ```javascript 8 | const html = require('nanohtml') 9 | const component = require('fun-component') 10 | const spawn = require('fun-component/spawn') 11 | 12 | const article = component(function article (ctx, props) { 13 | return html` 14 |
15 | ${props.img.alt} 16 |

${props.title}

17 |

${props.intro}

18 | Read more 19 |
20 | ` 21 | }) 22 | 23 | // use `props.id` as key to identify context 24 | article.use(spawn((props) => props.id)) 25 | 26 | function list (items) { 27 | return html` 28 |
29 | ${items.map((item) => article(item))} 30 |
31 | ` 32 | } 33 | ``` 34 | 35 | ## API 36 | 37 | ### `spawn(identity[, opts])` 38 | 39 | Returns a middleware function that creates a new context, identified by key. Takes a function and an (optional) options object. 40 | 41 | The function passed to spawn should return a unique key (`string`) that is used to identify which context to use for rendering. The identity function will be called whenever the component needs to render or update. The arguments used to call the component are forwarded to the identity function for you to use to determine the key. 42 | 43 | If the key is not recognized a new context is created. By default, the context is discarded when the element is removed from the DOM. 44 | 45 | #### `opts.persist` 46 | 47 | If set to `true` will keep the reference to the context after it is removed from the DOM. Defaults to `false`. 48 | 49 | #### `opts.cache` 50 | 51 | An object for storing context instances on. Will use common lru methods `get`, `set`, `remove` if defined, otherwise stores instances on the cache object by their identity. 52 | 53 | ```javascript 54 | var LRU = require('nanolru') 55 | var html = require('nanohtml') 56 | var component = require('fun-component') 57 | 58 | var cache = new LRU(3) // only ever allow a maximum of three instances of button 59 | var button = component(function button (ctx, id, text, onclick) { 60 | return html`` 61 | }) 62 | 63 | button.use(spawn((id) => id, {cache: cache})) 64 | 65 | module.exports = button 66 | ``` 67 | -------------------------------------------------------------------------------- /spawn/index.js: -------------------------------------------------------------------------------- 1 | var assert = require('nanoassert') 2 | var Context = require('../').Context 3 | 4 | // spawn component contexts on demand and optionally discard on unload 5 | // (fn, obj?) -> fn 6 | module.exports = function init (identity, opts) { 7 | assert(typeof identity === 'function', 'fun-component: identity should be a function') 8 | opts = opts || {} 9 | 10 | var cache = opts.cache || {} 11 | 12 | return function spawn (source) { 13 | var name = source._name 14 | var render = source._render 15 | var events = source._events 16 | var args = Array.prototype.slice.call(arguments, 1) 17 | var id = identity.apply(undefined, args) 18 | 19 | assert(typeof id === 'string', 'fun-component: identity should return a string') 20 | 21 | var ctx = typeof cache.get === 'function' ? cache.get(id) : cache[id] 22 | 23 | if (!ctx) { 24 | // spawn a new context 25 | ctx = new Context([name, id].join('_'), render) 26 | if (typeof cache.set === 'function') cache.set(id, ctx) 27 | else cache[id] = ctx 28 | 29 | if (!opts.persist) { 30 | // remove context from cache on unload 31 | ctx.on('unload', function () { 32 | if (typeof cache.remove === 'function') cache.remove(id) 33 | else delete cache[id] 34 | }) 35 | } 36 | 37 | // copy over all lifecycle event listeners to the new context 38 | var keys = Object.keys(source._events) 39 | for (var i = 0, len = keys.length; i < len; i++) { 40 | addEventListeners(ctx, keys[i], events[keys[i]]) 41 | } 42 | } 43 | 44 | return ctx 45 | } 46 | } 47 | 48 | function addEventListeners (ctx, event, listeners) { 49 | for (var i = 0, len = listeners.length; i < len; i++) { 50 | ctx.on(event, listeners[i]) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/browser.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var html = require('nanohtml') 3 | var component = require('../') 4 | var cache = require('../cache') 5 | var logger = require('../logger') 6 | var restate = require('../restate') 7 | var spawn = require('../spawn') 8 | 9 | test('browser', function (t) { 10 | t.test('render', function (t) { 11 | var render = component(greeting) 12 | 13 | t.plan(1) 14 | t.equal( 15 | render('world').toString(), 16 | greeting({}, 'world').toString(), 17 | 'output match' 18 | ) 19 | }) 20 | 21 | t.test('default update diff', function (t) { 22 | var render = component(greeting) 23 | render.on('afterupdate', function (ctx, el, name, callback) { 24 | callback() 25 | }) 26 | 27 | var next 28 | var element = render('world') 29 | createContainer(element) 30 | function fn () { next() } 31 | 32 | next = function () { t.pass('extra argument update') } 33 | render('world', fn) 34 | next = function () { t.pass('different argument update') } 35 | render('again', fn) 36 | next = function () { t.pass('handles falsy argument') } 37 | render(null, fn) 38 | next = function () {} 39 | var proxy = render('world', fn, element) 40 | next = function () { t.fail('proxy and element should be same') } 41 | render('world', fn, proxy) 42 | t.end() 43 | }) 44 | 45 | t.test('lifecycle events', function (t) { 46 | var state = { 47 | load: 0, 48 | unload: 0, 49 | update: 0, 50 | beforerender: 0, 51 | afterupdate: 0 52 | } 53 | 54 | var render = component(greeting) 55 | render.on('update', update) 56 | render.on('beforerender', beforerender) 57 | render.on('load', load) 58 | render.on('unload', unload) 59 | render.on('afterupdate', afterupdate) 60 | 61 | var node = render('world') 62 | var container = createContainer(node) 63 | 64 | function load (ctx, el, str) { 65 | state.load += 1 66 | t.ok(ctx instanceof component.Context, 'load: context is first argument') 67 | t.equal(el.id, ctx._ncID, 'load: element is forwarded') 68 | t.equal(str, 'world', 'load: arguments are forwarded') 69 | render('Jane') 70 | } 71 | function unload (ctx, el, str) { 72 | state.unload += 1 73 | t.ok(ctx instanceof component.Context, 'unload: context is first argument') 74 | t.equal(el.id, ctx._ncID, 'unload: element is forwarded') 75 | t.equal(str, 'Jane', 'unload: arguments are forwarded') 76 | t.deepEqual(state, { 77 | load: 1, 78 | unload: 1, 79 | update: 1, 80 | beforerender: 1, 81 | afterupdate: 1 82 | }, 'all lifecycle events fired') 83 | t.end() 84 | } 85 | function update (ctx, args, prev) { 86 | state.update += 1 87 | t.ok(ctx instanceof component.Context, 'update: context is first argument') 88 | t.equal(args[0], 'Jane', 'update: arguments are forwarded') 89 | t.equal(prev[0], 'world', 'update: prev arguments are forwarded') 90 | return true 91 | } 92 | function beforerender (ctx, el, str) { 93 | state.beforerender += 1 94 | t.ok(ctx instanceof component.Context, 'beforerender: context is first argument') 95 | t.equal(el.id, ctx._ncID, 'beforerender: element is forwarded') 96 | t.equal(str, 'world', 'beforerender: arguments are forwarded') 97 | } 98 | function afterupdate (ctx, el, str) { 99 | state.afterupdate += 1 100 | t.ok(ctx instanceof component.Context, 'afterupdate: context is first argument') 101 | t.equal(el.id, ctx._ncID, 'afterupdate: element is forwarded') 102 | t.equal(str, 'Jane', 'afterupdate: arguments are forwarded') 103 | window.requestAnimationFrame(function () { 104 | container.removeChild(node) 105 | }) 106 | } 107 | }) 108 | 109 | t.test('can have multiple event listeners', function (t) { 110 | t.plan(2) 111 | var times = 0 112 | var render = component(greeting) 113 | render.on('load', onload) 114 | render.on('load', onload) 115 | createContainer(render('world')) 116 | function onload (ctx, el, str) { 117 | times += 1 118 | t.pass('load event #' + times) 119 | } 120 | }) 121 | 122 | t.test('multiple update listeners', function (t) { 123 | t.plan(1) 124 | var render = component(greeting) 125 | 126 | // update if any update listener return true 127 | render.on('update', Boolean.bind(undefined, false)) 128 | render.on('update', Boolean.bind(undefined, true)) 129 | render.on('update', Boolean.bind(undefined, false)) 130 | 131 | render.on('afterupdate', t.pass.bind(t, 'did update')) 132 | createContainer(render('world')) 133 | render('again') 134 | }) 135 | 136 | t.test('remove lifecycle event listener', function (t) { 137 | var times = 0 138 | var render = component(greeting) 139 | render.on('update', onupdate) 140 | render.on('afterupdate', function () { 141 | times += 1 142 | }) 143 | createContainer(render('world')) 144 | render('world') 145 | window.requestAnimationFrame(function () { 146 | render.off('update', onupdate) 147 | render('world') 148 | t.equal(times, 1, 'should only update once') 149 | t.end() 150 | }) 151 | function onupdate () { 152 | return true 153 | } 154 | }) 155 | 156 | t.test('fork can override name', function (t) { 157 | t.plan(2) 158 | var first = component('first', greeting) 159 | var second = first.fork('second') 160 | first.on('beforerender', function (ctx) { 161 | t.equal(ctx._name, 'first', 'name is unchanged on base') 162 | }) 163 | second.on('beforerender', function (ctx) { 164 | t.equal(ctx._name, 'second', 'name is changed on fork') 165 | }) 166 | first() 167 | second() 168 | }) 169 | 170 | t.test('fork produce different nodes', function (t) { 171 | t.plan(1) 172 | var first = component(greeting) 173 | var second = first.fork() 174 | var container = createContainer(first()) 175 | container.appendChild(second()) 176 | t.equal(container.childElementCount, 2, 'two elements mounted') 177 | }) 178 | 179 | t.test('plugin: cache', function (t) { 180 | t.plan(6) 181 | var element 182 | var loaded = false 183 | var render = component(function (ctx, str) { 184 | if (!loaded) t.ok(typeof ctx.cached === 'undefined', 'ctx.cached is unset') 185 | return greeting(ctx, str) 186 | }) 187 | 188 | render.use(cache()) 189 | render.on('load', function (ctx) { 190 | if (!loaded) t.equal(ctx.cached, element, 'element is cached') 191 | loaded = true 192 | }) 193 | element = render('world') 194 | 195 | // Mount and await next frame 196 | var container = createContainer(element) 197 | window.requestAnimationFrame(function () { 198 | // Unmount and wait another frame 199 | container.removeChild(element) 200 | 201 | window.requestAnimationFrame(function () { 202 | // Render element from cache 203 | t.equal(element, render('again'), 'element is the same') 204 | 205 | // It should not have updated since it's not in the DOM atm 206 | t.notEqual(element.innerText, 'Hello again!', 'element did not update') 207 | 208 | // Mount yet again 209 | createContainer(element) 210 | window.requestAnimationFrame(function () { 211 | // Issue an update with new arguments 212 | t.ok(render('again').isSameNode(element), 'proxy node was returned') 213 | 214 | // It should have been updated now that it is in the DOM 215 | t.equal(element.innerText, 'Hello again!', 'element did update') 216 | }) 217 | }) 218 | }) 219 | }) 220 | 221 | t.test('plugin: logger', function (t) { 222 | t.plan(6) 223 | var render = component(greeting) 224 | render.use(logger()) 225 | render.use(function (ctx) { 226 | ctx.log._print = function (level, type) { 227 | t.equal(level, 'debug', `debug on ${type}`) 228 | } 229 | }) 230 | var element = render('world') 231 | var container = createContainer(element) 232 | window.requestAnimationFrame(function () { 233 | render('again') 234 | container.removeChild(element) 235 | }) 236 | }) 237 | 238 | t.test('plugin: restate', function (t) { 239 | t.plan(4) 240 | var render = component(function (ctx) { 241 | return greeting(ctx, ctx.state.name) 242 | }) 243 | 244 | render.use(restate({ name: 'world' })) 245 | render.on('load', onload) 246 | var element = render() 247 | t.equal(element.innerText, 'Hello world!', 'initial state applied') 248 | 249 | createContainer(element) 250 | window.requestAnimationFrame(function () { 251 | t.equal(element.innerText, 'Hello again!', 'state updated') 252 | }) 253 | 254 | function onload (ctx) { 255 | t.ok(typeof ctx.state === 'object', 'state in context') 256 | t.ok(typeof ctx.restate === 'function', 'restate in context') 257 | ctx.restate({ name: 'again' }) 258 | } 259 | }) 260 | 261 | t.test('plugin: spawn', function (t) { 262 | t.test('identify fn is required', function (t) { 263 | t.throws(spawn, 'throws w/o arguments') 264 | t.end() 265 | }) 266 | 267 | t.test('identify must return a string', function (t) { 268 | var render = component(greeting) 269 | render.use(spawn(function () { 270 | return 1 271 | })) 272 | t.throws(render, 'throws when id is not string') 273 | t.end() 274 | }) 275 | 276 | t.test('create and discard instances', function (t) { 277 | t.plan(3) 278 | var cache = {} 279 | var render = component(greeting) 280 | 281 | render.use(spawn(identity)) 282 | render.on('load', function (ctx, el, id) { 283 | if (cache[id]) t.notEqual(ctx._ncID, cache[id], `spawn ${id} was discarded`) 284 | cache[id] = ctx._ncID 285 | }) 286 | 287 | var one = render('one') 288 | var two = render('two') 289 | var container = createContainer() 290 | 291 | container.appendChild(one) 292 | container.appendChild(two) 293 | window.requestAnimationFrame(function () { 294 | t.notEqual(cache.one, cache.two, 'two contexts spawned') 295 | container.removeChild(one) 296 | container.removeChild(two) 297 | 298 | window.requestAnimationFrame(function () { 299 | container.appendChild(render('one')) 300 | container.appendChild(render('two')) 301 | }) 302 | }) 303 | }) 304 | 305 | t.test('use plain hash cache as option', function (t) { 306 | t.plan(2) 307 | var cache = {} 308 | var render = component(greeting) 309 | render.use(spawn(identity, {cache: cache})) 310 | render.on('load', function (ctx, el, id) { 311 | t.ok(cache.hasOwnProperty(id), 'ctx in cache') 312 | }) 313 | render.on('unload', function (ctx, el, id) { 314 | t.notOk(cache.hasOwnProperty(id), 'ctx removed from cache') 315 | }) 316 | var element = render('foo') 317 | var container = createContainer(element) 318 | window.requestAnimationFrame(function () { 319 | container.removeChild(element) 320 | }) 321 | }) 322 | 323 | t.test('use lru cache as option', function (t) { 324 | t.plan(7) 325 | var cache = {} 326 | var lru = { 327 | get: function (id) { 328 | t.equal(this, lru, '#get calling context is lru') 329 | t.equal(id, 'foo', '#get param is id') 330 | return cache[id] 331 | }, 332 | set: function (id, ctx) { 333 | t.equal(this, lru, '#set calling context is lru') 334 | t.equal(id, 'foo', '#set first param is id') 335 | t.ok(ctx instanceof component.Context, '#set second param is ctx') 336 | cache[id] = ctx 337 | }, 338 | remove: function (id) { 339 | t.equal(this, lru, '#remove calling context is lru') 340 | t.equal(id, 'foo', '#remove param is id') 341 | delete cache[id] 342 | } 343 | } 344 | var render = component(greeting) 345 | render.use(spawn(identity, {cache: lru})) 346 | var element = render('foo') 347 | var container = createContainer(element) 348 | window.requestAnimationFrame(function () { 349 | container.removeChild(element) 350 | }) 351 | }) 352 | 353 | t.test('respect persist option', function (t) { 354 | t.plan(1) 355 | var cache = {} 356 | var render = component(greeting) 357 | render.use(spawn(identity, {persist: true, cache: cache})) 358 | render.on('unload', function (ctx, el, id) { 359 | t.ok(cache.hasOwnProperty(id), 'ctx in cache after unload') 360 | }) 361 | var element = render('persisted') 362 | var container = createContainer(element) 363 | window.requestAnimationFrame(function () { 364 | container.removeChild(element) 365 | }) 366 | }) 367 | 368 | function identity (id) { 369 | return id 370 | } 371 | }) 372 | }) 373 | 374 | function greeting (ctx, name) { 375 | return html` 376 |
377 |

Hello ${name}!

378 |
379 | ` 380 | } 381 | 382 | function makeID () { 383 | return 'containerid-' + Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) 384 | } 385 | 386 | function createContainer (child) { 387 | var container = document.createElement('div') 388 | container.id = makeID() 389 | document.body.appendChild(container) 390 | if (child) { 391 | container.appendChild(child) 392 | } 393 | return container 394 | } 395 | -------------------------------------------------------------------------------- /test/node.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var html = require('nanohtml') 3 | var component = require('../') 4 | 5 | test('server side render', function (t) { 6 | t.test('render function required', function (t) { 7 | t.plan(2) 8 | t.throws(component, 'throws w/o arguments') 9 | t.throws(component.bind(undefined, 'name'), 'throws w/ only name') 10 | }) 11 | 12 | t.test('render', function (t) { 13 | var render = component(greeting) 14 | 15 | t.plan(1) 16 | t.equal( 17 | render('world').toString(), 18 | greeting({}, 'world').toString(), 19 | 'output match' 20 | ) 21 | }) 22 | 23 | t.test('mirror name', function (t) { 24 | var implicid = component(greeting) 25 | var explicid = component('greeting', greeting) 26 | 27 | t.equal(implicid.name, 'greeting', 'implicid name is mirrored') 28 | t.equal(explicid.name, 'greeting', 'explicid name is mirrored') 29 | t.end() 30 | }) 31 | 32 | t.test('Context class in export', function (t) { 33 | t.plan(1) 34 | t.ok(typeof component.Context === 'function', 'Context in export') 35 | }) 36 | 37 | t.test('context is first argument', function (t) { 38 | t.plan(1) 39 | var render = component(function (ctx, str) { 40 | t.ok(ctx instanceof component.Context, 'context is first argument') 41 | return greeting(ctx, str) 42 | }) 43 | render('world') 44 | }) 45 | 46 | t.test('render function is unbound', function (t) { 47 | t.plan(2) 48 | var strict = component(function (ctx, str) { 49 | 'use strict' 50 | t.ok(typeof this === 'undefined', 'no calling context in strict mode') 51 | return greeting(ctx, str) 52 | }) 53 | var nonstrict = component(function (ctx, str) { 54 | t.equal(this, global, 'global calling context in non-strict mode') 55 | return greeting(ctx, str) 56 | }) 57 | strict('world') 58 | nonstrict('world') 59 | }) 60 | 61 | t.test('can use plugin', function (t) { 62 | t.plan(7) 63 | var render = component(function (ctx, str) { 64 | t.ok(ctx.visited, 'context is forwarded') 65 | return greeting(ctx, str) 66 | }) 67 | t.ok(typeof render.use === 'function', 'has use method') 68 | var value = render.use(function (ctx, str) { 69 | t.ok(ctx instanceof component.Context, 'context is first argument') 70 | t.equal(str, 'world', 'arguments are forwarded') 71 | ctx.visited = true 72 | return ctx 73 | }) 74 | render.use(function (ctx) { 75 | 'use strict' 76 | t.ok(typeof this === 'undefined', 'no calling context in strict mode') 77 | return ctx 78 | }) 79 | render.use(function (ctx) { 80 | t.equal(this, global, 'global calling context in non-strict mode') 81 | return ctx 82 | }) 83 | t.ok(typeof value === 'undefined', 'use returns nothing') 84 | render('world') 85 | }) 86 | 87 | t.test('can add lifecycle event listeners', function (t) { 88 | t.plan(3) 89 | var render = component(function (ctx) { 90 | ctx.foo() 91 | return greeting() 92 | }) 93 | render.on('foo', function (ctx, arg) { 94 | t.pass('events are proxied as methods on ctx') 95 | t.ok(ctx instanceof component.Context, 'ctx is first argument') 96 | t.equal(typeof arg, 'undefined', 'arguments are not forwarded') 97 | }) 98 | render('foo') 99 | }) 100 | 101 | t.test('can fork', function (t) { 102 | t.plan(3) 103 | var first = component('first', function (ctx) { 104 | ctx.foo() 105 | return greeting() 106 | }) 107 | first.on('foo', function (ctx) { 108 | t.pass(`first event callback called for ${ctx._name}`) 109 | }) 110 | var second = first.fork('second') 111 | second.on('foo', function (ctx) { 112 | t.pass(`second event callback called for ${ctx._name}`) 113 | }) 114 | first() 115 | second() 116 | }) 117 | 118 | t.test('original render function in context', function (t) { 119 | t.plan(1) 120 | var render = component(greeting) 121 | render.use(function (ctx) { 122 | t.equal(ctx._render, greeting, 'ctx._render is same') 123 | return ctx 124 | }) 125 | render('world') 126 | }) 127 | }) 128 | 129 | function greeting (ctx, name) { 130 | return html` 131 |
132 |

Hello ${name}!

133 |
134 | ` 135 | } 136 | --------------------------------------------------------------------------------