UNPKG

21.1 kBMarkdownView Raw
1<p align="center"><img width="300" src="https://js.resorts-interactive.com/halfcab/halfcab.svg"></p>
2
3Halfcab is a universal JavaScript framework that assembles some elegant and easy to use libraries made by some very clever people, then adds some glue, sets some defaults, and hides a bit of the implementation so you don't have to worry about it.
4
5[![CircleCI](https://circleci.com/gh/lorengreenfield/halfcab.svg?style=shield)](https://circleci.com/gh/lorengreenfield/halfcab) [![Coverage Status](https://coveralls.io/repos/github/lorengreenfield/halfcab/badge.svg?branch=master)](https://coveralls.io/github/lorengreenfield/halfcab?branch=master) [![Greenkeeper badge](https://badges.greenkeeper.io/lorengreenfield/halfcab.svg)](https://greenkeeper.io/)
6
7## ES Modules required
8Halfcab is no longer built as a common js distribution. The `esm` package is required in node, and an es2015 bundler is required for the browser (webpack, rollup, etc)
9
10## What you get
11
12- Syntax is the "web platform" - JavaScript, CSS, HTML
13- Component based with es2015 template literals
14- Components contain JS, HTML and CSS altogether in one file
15- Both browser and server side component rendering
16- Easy state management
17- Form validation
18- Client side routing (use server side routing of your choice)
19- Markdown injection
20- Global event bus and localized event emitter
21- http requests (Axios)
22
23
24
25halfcab exposes a bunch of functions and objects that you import from the halfcab module. If you want to grab them all at once ( you don't ), it'd look like this:
26
27```js
28import halfcab, { html, css, cache, injectHTML, injectMarkdown, geb, eventEmitter, updateState, rerender, formField, formIsValid, fieldIsTouched, resetTouched, ssr, defineRoute, gotoRoute, http, getRouteComponent, nextTick } from 'halfcab'
29```
30
31## Installation
32`npm install halfcab --save`
33
34
35## How to use it
36
37#### Setup
38- `default` ( the default function sets up halfcab in the browser with your options )
39
40This happens in the browser only:
41
42```js
43import halfcab from 'halfcab'
44
45halfcab({
46 el: '#root', // selector to mount root component on
47 components, // top level component module
48}).then(({rootEl, state}) => {
49 // rootEl and global state made available here if needed
50}).catch(err => {
51 console.log(err)
52})
53```
54
55#### Components
56- `html` - creates dom elements from template literals
57- `css` - injects css into html component's class property
58- `cache` - wrapper function to increase performance of reusable components by caching them. Use later on in your project when making performance tweaks.
59- `injectHTML` - injects html from a string, much like a triple mustache or React's dangerouslySetInnerHTML
60- `injectMarkdown` - the same as `injectHTML` but first converts markdown into HTML, making sure HTML entities are not double encoded.
61
62
63Both injectHTML and injectMarkdown have a second argument for options. Currently there's just a single option:
64
65```{wrapper: false}``` (default is true)
66
67By default injected html will have a wrapping `<div>`. This is to ensure an html element can successfully be made (required by nanohtml)
68If you know that your HTML already has a wrapping element, or it's just a single element, you can set the wrapper to false. Particularly useful when dealing with SVGs.
69
70Under the hood, halfcab uses nanohtml + nanomorph, which turns tagged template literals into elements, and updates the DOM.
71
72Here's an example of a simple component:
73```js
74import { html } from 'halfcab'
75
76export default args => html`
77 <header>
78 <nav>
79 <div style="width: 280px;">
80 <img src="${args.company.logo.url}" />
81 </div>
82
83 <div style="width: 216px; text-align: center;" ${args.disabled ? {disabled} : ''}>
84 <button onclick=${e => {
85 alert('I am a button')}}
86 >Log in <i class="material-icons" style="vertical-align: inherit">account_circle</i>
87 </button>
88 </div>
89 </nav>
90 </header>
91`
92
93```
94
95This is just regular HTML with one twist - Using event handlers like onclick will use the scope of your component, not the global scope. Just don't use quotation marks around it. Put it within ${ } instead.
96
97halfcab uses csjs for inline css, like so:
98```js
99import { html, css } from 'halfcab'
100let cssVar = '#FFCC00'
101let styles = css`
102 .header > nav {
103 display: flex;
104 align-items: center;
105 justify-content: space-between;
106 flex-wrap: wrap;
107 align-content: center;
108 background-color: ${cssVar}
109 }
110
111 @media (max-width: 720px) {
112 .header > nav {
113 flex-direction: column;
114 }
115 }
116`
117
118export default args => html`
119 <header class=${styles.header}>
120 <nav>
121 <div style="width: 280px;">
122 <img src="${args.company.logo.url}" />
123 </div>
124
125 <div style="width: 216px; text-align: center;">
126 <button onclick=${e => {
127 alert('I am a button')}}>Log in <i class="material-icons" style="vertical-align: inherit">account_circle</i>
128 </button>
129 </div>
130 </nav>
131 </header>
132`
133
134```
135Notice how you can use media queries, and inject variables using JavaScript! The CSS is scoped to your component so doesn't affect the rest of the app.
136
137
138If you want a bit of a performance boost with components that you know will be re-rendered quite often, but won't change (args passed in will always be the same), use the `cache` wrapper function. You can use this with all components except the root one. The caching uses the function and its arguments as a key, and since the root element always receives the full global state, it will never update. Don't bother using this unless you need a performance boost. Using caching relies on all state being sent through the component tree, so if you need to pull state from somewhere else other than the global state object, do it in the parent component and pass it down.
139
140```js
141import {html, formField, cache} from 'halfcab'
142
143const singleField = ({holdingPen, name, property, styles, type, required, pattern}) => html`
144 <div>
145 <input value="${holdingPen[property]}" type="${type}" oninput=${formField(holdingPen, property)} class="${styles.input}" ${required ? `required` : ''} />
146 <label>${name}</label>
147 <div></div>
148 </div>
149`
150
151export default args => cache(singleField, args)
152```
153This essentially acts as a component cache. Notice that instead of just returning your component as the default function, you're simply creating a separate constant that holds the function, and then passing that function, along with the args, into the cache wrapper function.
154
155You'll end up with slightly better performance than React (but not React Fibre) using the `cache` wrapper. See the [halfcab Sierpinski Triangle example](https://resorts-interactive.com/uiperftest.html).
156
157It's worth mentioning here a second time, **don't wrap your root component**. Everything else is fine.
158
159
160#### Events
161- `geb` - global event bus
162- `eventEmitter` - instantiate this for localised events
163
164`geb` has 4 methods - broadcast, on, once, off.
165
166Most often you'll use broadcast and on.
167
168Listen for an event:
169```js
170import { geb } from 'halfcab'
171geb.on('doStuff', (passedInArgs, state) => {
172 // note that the global state is passed in as the second argument to all geb listeners
173 alert('Stuff happened')
174})
175```
176
177Broadcast an event:
178```js
179import { geb } from 'halfcab'
180geb.broadcast('doStuff', argsObject)
181```
182
183The off method will turn off listening to events, and you'll need a named function to reference, eg:
184```js
185var myFunc = (passedInArgs, state) => {
186 alert('Stuff happened from myFunc')
187}
188geb.on('doStuff', myFunc)
189
190//......sometime later on
191
192geb.off('doStuff', myFunc)
193```
194
195The `once` method is just like `on`, but once the event has been executed, it'll be switched off.
196
197If you don't want your events to be global, new up an `eventEmitter` like so:
198
199```js
200import { eventEmitter } from 'halfcab'
201var localEvents = new eventEmitter()
202
203```
204Then just use `localEvents` as you would `geb`
205
206#### State management
207- `updateState` - update the global state object. You can choose to do shallow or deep merging. If you want to you can achieve Redux style updates by using `geb`. Calling updateState will cause the state object to be updated and then re-rendered.
208- `rerender` - The global state is passed into the root component and is mutable, if you want to make deep changes within a component by mutating the state directly without using updateState, you can do so, followed by `rerender()`. By comparison, updateState merges/mutates the state and then runs rerender for you.
209
210```js
211import { updateState, geb } from 'halfcab'
212
213geb.on('fieldUpdate', (newValue, state) => {
214 updateState({
215 field: {
216 value: newValue
217 }
218 }, {
219 deepMerge: false, // deep merging on by default, set to false to overwrite whole objects without merging
220 arrayMerge: false, // if deep merging then arrays will also merge by default. Set to false to not merge arrays.,
221 rerender: false // if you want to control rerendering yourself (if you want to wait to async functions to complete) then set rerender to false and call the rerender() function later on when you're ready
222 })
223})
224
225//....sometime later from somewhere else in the app
226
227geb.broadcast('fieldUpdate', 23)
228
229```
230
231#### Utilities
232- `formField` - an easy way to create a holding pen object for form changes before sending it to the global state - good for when using oninput instead of onchanged or if you only want to update the global state once the data is validated.
233- `formIsValid` - test if a holdingPen object's values are all valid. Halfcab will automatically populate a `valid` object within the holding pen that contains the same keys - this can either be object.valid or object[Symbol('valid')]. The validity of these is best set when you define the holding pen's initial values. Likewise, there's a `touched` object with the same keys that keeps track of which form elements have received focus at least once - helpful for form validation feedback.
234- `nextTick` - Run a function at the next batched update. Usage: `nextTick(myFunction)`
235
236eg.
237
238```js
239import {html, formField, formIsValid, fieldIsTouched} from 'halfcab'
240
241let holdingPen = {
242 value: '',
243 valid: {
244 value: true//The starting value is considered valid
245 }
246}
247// alternatively use holdingPen with a symbol valid object
248let holdingPen = {
249 value: '',
250 [Symbol('valid')]: {
251 value: true//The starting value is considered valid
252 }
253}
254
255let holdingPen = {
256 value: '',
257 [Symbol('valid')]: {
258 value: true
259 },
260 [Symbol('touched')]: { // When formField is run against a property, its touched value is set to true
261 value: true
262 }
263}
264```
265
266Use the touched value to attach a .touched class to your inputs so that you can only style them as invalid once the user has had a chance to interact with them
267eg.
268```js
269 let input = html`<input class="${styles.checkbox} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" type="checkbox" />`
270```
271
272export default args => html`
273 <main>
274 <div>
275 <input type="text" oninput=${formField(holdingPen, 'value')}>
276 <label for="my-textfield">Hint text</label>
277 </div>
278 </main>
279`
280
281// ...sometime later, perhaps when subitting the form
282
283if(!formIsValid(holdingPen)){
284 alert('Form not valid')
285}
286```
287
288#### Reset fields being touched
289
290Once you've submitted your form, you might want to set all touched fields to false again if you're emptying out anything like a password field. Use `resetTouched` to do so:
291
292```js
293resetTouched(holdingPen)
294```
295
296If you're dynamically wanting to change your form, you'll want to also alter the corresponding holdingPen so that you can correctly validate it
297
298You can add and remove properties to the holdingPen and retain correct validation by using the `addToHoldingPen` and `removeFromHoldingPen` functions.
299
300eg:
301
302```js
303let holdingPen = {
304 email: '',
305 password: '',
306 [Symbol('valid')]: {
307 email: false,
308 password: false
309 },
310 [Symbol('touched')]: {
311 email: false,
312 password: false
313 }
314}
315
316addToHoldingPen(holdingPen, {
317 test1: '',
318 test2: '',
319 [Symbol('valid')]: {
320 test1: false,
321 test2: false
322 },
323 [Symbol('touched')]: {
324 test1: false,
325 test2: false
326 }
327})
328
329removeFromHoldingPen(holdingPen, [
330 'test1',
331 'password'
332])
333
334```
335
336The above will leave you with a holding pen that has this structure:
337```js
338holdingPen = {
339 email: '',
340 test2: '',
341 [Symbol('valid')]: {
342 email: false,
343 test2: false
344 },
345 [Symbol('touched')]: {
346 email: false,
347 test2: false
348 }
349}
350```
351
352test1 and test2 properties were added using an object with initial values, and then test1 and property were removed, using an array, since you're removing, you don't have to provide key values, just the keys.
353
354#### Server side rendering
355- `ssr` - wrap root component with the ssr function on the server to return an object with componentsString and stylesString properties to inject into your HTML base template *See the full example at the bottom of this document for usage*
356
357To prevent doubling up on api calls (one from the SSR and one from the browser), you can send your initial data for the app to get things going and include it in your HTML using base 64 encoded JSON attached to a script tag like so:
358
359```html
360<script data-initial="${new Buffer(JSON.stringify(apiData)).toString('base64')}"></script>
361
362```
363This code is generated within node, so we have `Buffer` available to do base64 encoding. halfcab will decode this in the browser and set the first state with it, along with router information. (The state object contains a top level router object with a pathname property)
364```js
365state = {
366 router: {
367 pathname: '/reportpal'
368 }
369
370 // other stuff
371}
372
373```
374
375
376#### Browser routing
377- `defineRoute` - create a route
378- `gotoRoute` - manually invoke a route ( otherwise, using `a` tags with href will do it for you )
379
380halfcab tries not to force you to use a single solution for both server side and browser routing. Provide your own server side routing and then use `route` for setting up browser routes from halfcab.
381
382Create a new route:
383```js
384import { defineRoute } from 'halfcab'
385defineRoute({path: '/reportpal', title: 'Report Pal', component: 'myPageComponent', callback(routeInfo){
386 //this callback with route info is useful for making supplementary api calls
387 console.log(routeInfo) // routInfo contains params, hash, query, href, pathname
388}})
389
390```
391To create a route that is on the same domain, but outside the realms of your client side routing management (microsites, etc), use the external boolean:
392```js
393import { defineRoute } from 'halfcab'
394defineRoute({path: '/reportpal', external: true})
395```
396
397The `path` option sets the route path. Remember to include a forward slash as the first character of the route or if jumping to another site, http(s)://.
398The title sets the HTML title of the page and tab when the route is hit.
399
400The `component` option allows you to set a value for automatically swapping out components. You can retreive route components us using getRouteComponent(key)
401You would typically use this with the current route's component, using router.key like so:
402```js
403let routedComponent = state => html`
404 <div>${getRouteComponent(state.router.key)(state)}</div>
405`
406
407```
408You *could* set this as the component itself, but to make server side rendering easy and able to provide a component without needing a client side render, you should provide a string which you can then use to load in a component, for example:
409
410```js
411defineRoute({path: '/reportpal',
412 title: 'Report Pal',
413 component: 'myPageComponent',
414 callback(routeInfo){
415 // this callback with route info is useful for making supplementary api calls
416 console.log(routeInfo)//routInfo contains params, hash, query, href, pathname
417}})
418
419// ...and in the component where you want the route change to automatically switch things:
420
421import { html } from 'halfcab'
422
423export default state => html`
424 <div id="root">${getRouteComponent(state.router.key)(state)}</div>
425`
426```
427If you want to pick a component based on something other than the route:
428
429```js
430import myPageComponent from './myPageComponent'
431import myPageOtherComponent from './myPageOtherComponent'
432let routeComponents = {
433 myPageComponent,
434 myPageOtherComponent
435}
436
437import { html } from 'halfcab'
438
439export default state => html`
440 <div id="root">${routeComponents[state.somevalue](state)}</div>
441`
442
443```
444
445Once your routes are set up using the `defineRoute` function, there's two ways to tell your app to go to that route.
4461. a-href - Just use `a` tags as you normally would with the `href` property and the router will pick that up and route the app. The bonus of this approach is that using href is an easy way to help crawlers find their way through your site, and if structured carefully, you can also have basic navigation without the need for running JavaScript in the browser.
447
4482. Use the `gotoRoute` function
449```js
450import { gotoRoute } from 'halfcab'
451gotoRoute('/my-local-route')
452```
453
454### Other things worth mentioning
455
456#### Network requests
457halfcab uses `axios` internally for api calls and exports this as the `http` object for use in your own code. *See the axios docs on how to use it*
458
459
460### Putting it all together and structuring your code
461
462Both browser and server code will pull in your top level component, but they each have entirely different entry points. This allows you to determine how the components are rendered in each environment and means you don't have to include all the client dependencies while doing server side rendering. It also means you can stick with your preferred server side routing ( hapi, express, koa, etc )
463
464###### Server file structure
465
466```
467route.mjs
468 |
469 |
470 | ---imports - htmlTemplate.mjs
471 |
472 | --- imports - components.mjs ( executed with initialData, which is also injected into the HTML head )
473
474```
475
476route.mjs could have a lot of other things going on ( it's up to you and your server side framework ) but the key part for us is that it runs:
477```js
478import htmlTemplate from './htmlTemplate'
479
480//somewhere else in file after http GET request to the route comes in:
481htmlTemplate(someJSONData)
482
483```
484
485htmlTemplate.mjs
486
487```js
488import pack from '../../../package'
489import components from '../../../components'
490import { ssr } from 'halfcab'
491import { minify } from 'html-minifier'
492
493
494function htmlOutput(data){
495 let apiData = data[0]
496 let { componentsString, stylesString } = ssr(components(apiData))
497
498 return `<!DOCTYPE html>
499 <html lang="en">
500 <head>
501 <title>Resorts Interactive</title>
502 <link rel="shortcut icon" href="/staticimages/favicon.png" type="image/png"/>
503 <link rel="stylesheet" href="/css/${pack.version}">
504 <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
505 <meta name="viewport" content="width=device-width, user-scalable=no"/>
506 <style>${stylesString}</style>
507 <script id="initialData" data-initial="${new Buffer(JSON.stringify(apiData)).toString('base64')}"></script>
508 <script src='/js/${pack.version}' defer></script>
509 </head>
510 <body style="padding: 0px; margin: 0px;">
511
512 ${componentsString}
513
514 </body>
515 </html>
516`
517}
518
519
520export default data => minify(htmlOutput(data), {
521 collapseWhitespace: true,
522 minifyCSS: true
523})
524```
525
526###### Browser JS structure
527
528```
529app.mjs
530 |
531 | ---imports - components.mjs
532 |
533 | ---imports - halfcab ( executed with components, automatically pulls in latest state (starting with injected initialData )
534
535
536
537```
538app.mjs
539```js
540import halfcab from 'halfcab'
541import './server/**/client' // registers client routes
542import components from './components'
543
544halfcab({
545 el: '#root',
546 components
547}).catch(err => {
548 console.log(err)
549})
550
551```
552Notice:
5531. This browser code is also creating an mock function to add to the cd object, but this time, it's actually importing the real someBrowserOnlyLib library and using it before returning the element.
5542. The halfcab function returns a promise that returns our root element ready for us to use.
555
556###### The common file between server and browser - components.mjs
557
558```js
559import { html } from 'halfcab'
560import topNav from './topNav'
561import body from './body'
562import footer from './footer'
563
564function products(products){
565 return {
566 seemonster: products.find(item => item.name === 'SeeMonster'),
567 vicomap: products.find(item => item.name === 'vicoMap'),
568 reportpal: products.find(item => item.name === 'Report Pal')
569 }
570}
571
572export default args => cd.mock(html`
573 <div id="root" style="margin-top: 10px; text-align: center;">
574 ${topNav({
575 company: args.company,
576 products: products(args.products)
577
578 })}
579 ${body({
580 products: products(args.products),
581 company: args.company
582 })}
583 ${footer()}
584 ${injectHTML(args.safeHTMLFromServer)}
585 </div>
586`)
587```
588
589This is our top level component, from here we're also pulling in three other components - topNav, body, and footer. This is the start of the tree-like component structure.