Last active
March 25, 2023 19:39
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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