glowup v0.7.1
I'm using some of The Pandemic to make my own web tools, which includes this frontend
module I'm calling glowup
.
Please enjoy.
-C
glowup
glowup
(npm) is a lightweight set of functions for
building client applications that live close to the browser.
It's probably best defined by what it lacks:
- No virtual DOM
- No compiler or bundler requirements
- No class based abstractions
- No third party dependencies
It's MIT licensed. Have at it.
import { Tag, replace } from "https://unpkg.com/glowup@0.7.1/glowup.js"
replace(document.body, App())
function App() {
let $button
const onAttach = () => {
console.log("This app was made with glowup!")
console.log("https://github.com/connorspeers/glowup")
}
const onClick = () => {
$button.disabled = true
$button.textContent = "Redirecting you to the future..."
setTimeout(() => window.location = "https://buildbackbetter.com", 2000)
}
return Body({
className: "app",
onAttach,
},
$button = Button({
className: "app-button",
textContent: "Tired of the last four years?",
onClick,
}),
)
}
function Body(p, ...c) { return Tag("body", p, ...c) }
function Button(p, ...c) { return Tag("button", p, ...c) }
Tag
Intro
Tag is HTML-in-JS for glownups. It creates an element with the given tag name, assigns the props, and appends the children. There's a handful of props that are handled differently from the others, but that's the gist.
/**
* @param {string} name - The tagName to provide to document.createElement
* @param {?Object} props - Properties to assign to the element after creation
* @param {...(Node|string|false)} children - Falsy-filtered children to append
* @returns {Element} - The fully-composed Element
**/
export function Tag(name, props, ...children) {
// ...
}
Its novelty comes from the pattern it unlocks when you use it like this:
// p -> props, c -> children
function Body(p, ...c) { return Tag("body", p, ...c) }
function Button(p, ...c) { return Tag("button", p, ...c) }
// etc.
These one-liners can then be used in consuming components in ways that are structurally similar to HTML markup without the need for compilers.
function App() {
return Body(null,
Button({
textContent: "Tired of the last four years?",
onClick() {
this.disabled = true
this.textContent = "Redirecting you to the future..."
setTimeout(() => window.location = "https://buildbackbetter.com", 2000)
},
}),
)
}
You can capture element references in-place by using regular variables and assignment; no
need for document.querySelector()
or complex APIs. The following will work the same as
the code above:
function App() {
let $button
const onClick = () => {
$button.disabled = true
$button.textContent = "Redirecting you to the future..."
setTimeout(() => window.location = "https://buildbackbetter.com", 2000)
}
return Body(null,
// Statements like this evaluate to the right hand side when used as values
$button = Button({
onClick,
textContent: "Tired of the last four years?",
}),
)
}
There isn't any state-driven UI patterns by default, but it's not that hard to write your own.
function App() {
let $app
let count = 0
const onClick = () => {
count++
$app.replaceWith(render())
}
const render = () => {
return $app = Body(null,
Button({
onClick,
textContent: `Clicked ${count} time${count === 1 ? '' : 's'}`,
}),
)
}
return render()
}
(Mind you, this is definitely a slow way to do state-driven UI. But so is React, even with the virtual DOM. 🤷♂️)
Props
The props are simply assigned to the created element in most cases, but there's some props that are non-standard and handled specially:
on[EventName]
: As seen above, props starting with "on" with a third character equal to a capitalized letter are added to the element as event listeners. As of right now, they aren't checked to be functions first; they are added indiscriminately. To get the event name to listen for the "on" is removed and the rest of the property name is.toLowerCase()
'd.merge
: Some element properties require complex merging when a callee component wants to forward property values from a caller. Themerge
prop is supposed to be an object of more props that will be applied after the current batch of props is finished being handled. Generally, merged props will override old values, but some (like event listeners) will compound instead of overriding. Example: Clicking theHelloWorld
button below will cause "HELLO" and "WORLD" to be printed to the console, in that order.function HelloWorld() { return Hello({ onClick() { console.log("WORLD") }, }) } function Hello(props) { return Button({ merge: props, onClick() { console.log("HELLO") }, }) }
Further, some merged props are special cases because overwriting previous values either wouldn't work or would be anti-pattern:
className
: When merging this string, the new value is concatenated to the old value with a space separatorclassList
: This array of strings is falsy-filtered and added using theelement.classList.add()
methodstyle
: This object merges withelement.style
, and includes CSS variable supportdataset
: This object merges withelement.dataset
- ...Others? I'm sure there's more that require special merging logic but I haven't added them yet. It's on the todo list.
Children
The list of children can be DOM nodes, strings, or the value false
and are appended to
the element like usual after falsy-filtering. Falsy-filtering allows you to use short
circuit rendering syntax if that's your thing. (Like how it works in React. But remember,
re-rendering on every state update is fundamentally slow!)
dispatch
This function dispatches a non-bubbling, non-trickling event to the target.
For consistency with other event dispatchers, the original target is available on the
detail object as .originalTarget
.
/**
* @param {EventTarget} target - The element to dispatch the event to
* @param {string} typeArg - Passed into the CustomEvent constructor
* @param {Object} [detail={}] - Will be made available on the dispatched event
**/
export function dispatch(target, typeArg, detail={}) {
// ...
}
"Why?" you ask...
// How it started
target.dispatchEvent(new CustomEvent("mycustomevent", { detail: { some: "data" } }))
// How it's going
dispatch(target, "mycustomevent", { some: "data" })
Yeah. Functions are great. Even the little ones.
bubble
This works the same as dispatch, but the dispatched event will bubble up through the tree.
/**
* @param {EventTarget} target - The target
* @param {string} typeArg - The type string passed into the CustomEvent constructor
* @param {Object} [detail={}] - Will be made available on the dispatched event
**/
function bubble(target, typeArg, detail={}) {
// ...
}
trickle
This works the same as dispatch, but the dispatched event will trickle down to every node in the subtree rooted at the target. The parent node receives the event before the children, recursively.
Because this is a non-standard event distribution method, the event's .target
will
always be strictly equal to the event's .currentTarget
. To get around this, there is an
.originalTarget
property available on the detail object which cannot be overridden.
/**
* @param {EventTarget} target - The target
* @param {string} typeArg - The type string passed into the CustomEvent constructor
* @param {Object} [detail={}] - Will be made available on the dispatched event
**/
function trickle(target, typeArg, detail={}) {
// ...
}
Example: Clicking the button in the following App()
will print "HELLO 2" and "WORLD 1"
to the console, in that order. The count in the detail is included to demonstrate that the
detail object is re-used for every dispatched element, not re-created or cloned.
function App() {
let $app
const log = (word, detail) => {
console.log(`${word} ${detail.count}`)
detail.count--
}
const click = () => {
trickle($app, "helloworld", { count: 2 })
}
return $app = Body({
onHelloWorld: event => log("HELLO", event.detail),
},
Button({
textContent: "Click here and look at the console",
onHelloWorld: event => log("WORLD", event.detail),
onClick: click,
}),
)
}
The functions described in the next section all trigger the trickling events "attach" and
"detach", which occur whenever an element is attached or detached from the document.body
subtree.
append
, replace
, and remove
append
will append a node with a list of children after falsy-filtering
/**
* @param {Node} node - The node to append to
* @param {...(Node|string|false)} children - Falsy-filtered nodes to append
**/
export function append(node, ...children) {
// ...
}
replace
will replace a node with a list of nodes after falsy-filtering
/**
* @param {Node} node - The node to replace
* @param {...(Node|string|false)} replacements - Falsy-filtered replacement nodes
**/
export function replace(node, ...replacements) {
// ...
}
remove
will remove a list of nodes from their parents (if any) after falsy-filtering
/**
* @param {...(Node|false)} nodes - Falsy-filtered nodes to remove
**/
export function remove(...nodes) {
// ...
}
These functions trigger the "attach" and "detach" trickle events. An element receives
the "attach" trickle event when it enters the document.body
tree, and it receives the
"detach" event when it leaves the document.body
tree. An element that is already
attached won't receive the attach event, and the same goes for detach. See the first
example in this file to see the "attach" trickle event in action.
Usage
The usual ways:
unpkg + https - The following should work in 93% of browsers:
// app.js import { Tag, append, replace, remove, dispatch, bubble, trickle, } from "https://unpkg.com/glowup" // Build app and attach it to document.body somehow here
Example html file which uses this script:
<!-- index.html --> <!DOCTYPE html><html lang="en"><head> <title>glowup: unpkg + https example</title> <meta charset="utf-8"> <script type="module" src="/app.js"></script> </head><body> <noscript>i'm lazy and didn't replace this noscript tag, oopies</noscript> </body></html>
Notice how there's no compile steps or shell commands to run?
npm + bundling - You can include this package as a dependency with:
npm install glowup
And then in your scripts:
import { Tag, append, replace, remove, dispatch, bubble, trickle, } from "glowup"
Finally, when building, you'd bundle it with the rest of your code using your favorite flavor of bundler. I liked rollup more than webpack, but you do you.
That's all folks! Thanks for reading.
Up next:
- Finish finding the props which need special merging logic
- - Testing (Deadline: v1)
- Find and document drawbacks to this pattern
- Explore other useful function ideas (render helpers?)
- Examples are the best documentation
- In-depth compare/contrast between this and popular web frameworks like React and Vue