Skip to content

Instantly share code, notes, and snippets.

@gordonbrander
Last active March 25, 2023 19:39
Show Gist options
  • Save gordonbrander/ad9b5d9ebefc69f489276413a4154e86 to your computer and use it in GitHub Desktop.
Save gordonbrander/ad9b5d9ebefc69f489276413a4154e86 to your computer and use it in GitHub Desktop.
Modest - a modest framework for unambitious web apps. One file. No dependencies. No build steps.
import {StoreElement, change} from './modest.js'
class ModestApp extends StoreElement {
static template() {
return `
<style>
:host {
display: block;
}
.time {
font-family: monospace;
font-size: 24px;
font-weight: bold;
}
</style>
<div id="root">
<div id="time"></div>
<button id="button">Click me</button>
</div>
`
}
static init() {
return {
now: 0
}
}
static update(state, msg) {
if (msg.type === "time") {
return change({...state, now: msg.value})
} else {
return change(state)
}
}
write(state, send) {
let button = this.shadowRoot.querySelector('#button')
button.onclick = () => send({type: 'time', value: Date.now()})
let time = this.shadowRoot.querySelector('#time')
time.textContent = state.now
}
}
customElements.define('modest-app', ModestApp)
// A single transaction for a state change.
// Returned from update functions.
export class Change {
constructor(state, effects=[]) {
this.state = state
this.effects = effects
}
mergeEffect(effect) {
return new Change(state, [...this.effects, effect])
}
mergeEffects(effects) {
return new Change(state, [...this.effects, ...effects])
}
}
// Create a transaction.
export const change = (state, fx=[]) => new Change(state, fx)
export const forward = (send, tag) => msg => send(tag(msg))
// Create an update function for a small part of a state.
// Maps effects.
export const cursor = ({get, put, tag, update}) => (big, msg) => {
let small = get(big)
if (small == null) {
return change(big)
}
let next = update(small, msg)
return change(
put(big, next.state),
next.effects.map(effect => effect.then(tag))
)
}
// A store that shedules an animation frame when a state change happens...
//
// Renders are batched, so you get max one write per animation frame, even
// if state is updated multiple times per frame.
export const Store = ({flags=null, init, update, render}) => {
let isFrameScheduled = false
let state = init(flags)
const frame = () => {
isFrameScheduled = false
render(state, send)
}
const send = msg => {
let next = update(state, msg)
if (state !== next.state) {
state = next.state
if (!isFrameScheduled) {
isFrameScheduled = true
requestAnimationFrame(frame)
}
}
for (let effect of next.effects) {
run(effect)
}
}
const run = async fx => {
let action = await fx
send(action)
}
// Issue first render immediately
render(state, send)
return {send, run}
}
// Creates a cache of document fragments, memoized by template string.
export const FragmentCache = () => {
let cache = new Map()
const clear = () => {
cache.clear()
}
const fragment = string => {
if (cache.get(string) == null) {
let templateEl = document.createElement('template')
templateEl.innerHTML = string
cache.set(string, templateEl)
}
let templateEl = cache.get(string)
let fragmentEl = templateEl.content.cloneNode(true)
return fragmentEl
}
fragment.clear = clear
return fragment
}
// Default fragment cache
export const Fragment = FragmentCache()
// An element that is a function of state.
// Only re-renders when state has changed.
export class RenderableElement extends HTMLElement {
static template() { return "" }
#state
constructor() {
super()
this.attachShadow({mode: 'open'})
let fragment = Fragment(this.constructor.template())
this.shadowRoot.append(fragment)
this.render = this.render.bind(this)
}
// Set state of element, triggering a write if state changed
render(state, send) {
if (this.#state !== state) {
this.#state = state
this.write(state, send)
}
}
write(state, send) {}
}
// A stateful element that holds a store.
// Useful for a single top-level root, or for island architecture.
export class StoreElement extends RenderableElement {
static template() { return "" }
static init() {}
static update(state, msg) {
return change(state)
}
constructor() {
super()
this.attachShadow({mode: 'open'})
let fragment = Fragment(this.constructor.template())
this.shadowRoot.append(fragment)
let store = Store({
init: this.constructor.init,
update: this.constructor.update,
flags: this.flags,
render: (state, send) => this.write(state, send)
})
this.send = store.send
}
get flags() {}
write(state, send) {}
}
export const tagItem = id => value => ({id, value})
const isListMatchingByID = (items, states) => {
if (items.length !== states.length) {
return false
}
for (let i = 0; i < states.length; i++) {
let item = items[i]
let state = states[i]
if (item._id !== state.id) {
return false
}
}
return true
}
// List ID'd elements in parent.
// Models must have an `id` property, which will be assigned to the
// `id` of the element.
//
// If a state has moved in relation to the rest of the list, the old element
// will be removed and re-added in the new location.
export const list = (tag, parent, states, send) => {
// If all state IDs match all list IDs, just loop through and write.
// Otherwise, rebuild the list.
if (isListMatchingByID(parent.children, states)) {
for (let i = 0; i < states.length; i++) {
let item = parent.children[i]
let state = states[i]
item.render(state, forward(send, tagItem(item._id)))
}
} else {
let items = []
for (let state of states) {
let item = document.createElement(tag)
item._id = state.id
item.render(state, forward(send, tagItem(item._id)))
items.push(item)
}
// Replace any remaining current nodes with the children array we've built.
parent.replaceChildren(...items)
}
}
export const _ = (scope, selector) =>
scope.querySelector(`:scope ${selector}`)
// Shorthand for Object.freeze
export const freeze = Object.freeze
// Put a value to a key, returning a new frozen object
export const put = (state, key, val) => freeze({...state, [key]: val})
// Append an array to another array.
// Returns a frozen array.
export const append = (a, b) => freeze([...a, ...b])
// Merge a patch onto a state.
// Returns a new frozen state.
export const merge = (state, patch) => freeze({...state, ...patch})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment