├── .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 | [](https://npmjs.org/package/fun-component) [](https://travis-ci.org/tornqvist/fun-component)
6 | [](https://npmjs.org/package/fun-component)
7 | [](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 |
33 | Clicked ${clicks} times
34 |
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`${text} `)
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 | ctx.rerender()}>What time is it?
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`${text} `
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 |
23 | ${ctx.state.expanded ? 'Hide' : 'Show'} preview
24 |
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 |
69 | ${input(id, state[id] || '', oninput(id))}
70 | ${expandable(id, state[id] || 'Nothing here')}
71 |
72 | `)}
73 |
74 | Add file
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 | Number
68 | Name
69 | Year Discovered
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 | Where am I?
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 |
${ctx.state.expanded ? 'Close' : 'Open'}
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 |
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`${text} `
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 |
--------------------------------------------------------------------------------