Skip to content

Instantly share code, notes, and snippets.

@fponticelli
Last active July 30, 2018 01:29
Show Gist options
  • Save fponticelli/78db1cf2a0daf830e34989d393be70bd to your computer and use it in GitHub Desktop.
Save fponticelli/78db1cf2a0daf830e34989d393be70bd to your computer and use it in GitHub Desktop.
import { DOMView, div, up, button, down, when, repeat, Spoon } from './lib'
type State = { value: number }
class Adjust {
kind: 'adjust' = 'adjust'
constructor(
readonly value: number
) {}
}
class SetValue {
kind: 'set' = 'set'
constructor(
readonly value: number
) {}
}
type Message = Adjust | SetValue
const description: DOMView<State, Message> = div(
{ 'class': 'container' },
up(
{ map: (v: number) => new Adjust(v) },
button(
{ onclick: (v: State) => (_: Event) => -1 },
'-'
),
button(
{ onclick: (v: State) => (_: Event) => 1 },
'+'
)
),
' value: ',
down(
{ map: o => o.value },
String,
when<number, Message>(
{ condition: v => v !== 0 },
[
' ',
button(
{
onclick: (v: State) => v.value === 0 ? undefined : (_: Event) => new SetValue(0),
disabled: (v: State) => v.value === 0 ? 'disabled' : undefined
},
'reset'
)
]
),
when(
{ condition: v => v > 0 },
[
' ',
down(
{ map: v => Array.apply(null, {length: v}).map(Number.call, Number).map((v: number) => v + 1) },
repeat(
down(
{ map: String },
v => `${v} `
)
)
)
], [
', you should increment that number'
]
)
)
)
window.addEventListener('DOMContentLoaded', (_) => {
Spoon.mount<State, Message>(
document.body,
description,
{ value: 0 },
(state: State, message: Message) => {
switch (message.kind) {
case 'adjust':
return { ...state, value: state.value + message.value }
case 'set':
return { ...state, value: message.value }
}
}
)
})
export interface View<T, M, N> {
mount(
state: T,
append: (node: N) => void,
dispatch: (message: M) => void,
): MountedView<T>
}
export interface MountedView<T> {
unmount(): void
render(value: T): void
}
export type DOMView<T, M> = View<T, M, Node>
export type DOMText<T> = string | ((state: T) => string)
export type DOMChild<T, M> = DOMView<T, M> | DOMText<T>
export const domChildToView = <T, M>(dom: DOMChild<T, M>): DOMView<T, M> => {
if (typeof dom === 'string' || typeof dom === 'function')
return text(dom)
else
return dom
}
export type DOMMountedView<T> = MountedView<T>
export type DAttribute<T, V> = undefined | V | ((state: T) => undefined | V)
export type DEvent<T, M> = (state: T) => (undefined | ((event: Event) => (undefined | M)))
export interface DDynamicAttribute<T, V> {
attribute: string,
valuef: ((state: T) => undefined | V)
}
export interface DEventObject<T, M> {
event: string,
handler: (state: T) => (undefined | ((event: Event) => (undefined | M)))
}
const removeNode = (node: Node) => {
if (node && node.parentElement) {
node.parentElement.removeChild(node)
}
}
export class DElement<T, M> {
constructor(
readonly name: string,
readonly attributes: Record<string, DAttribute<T, any> | DEvent<T, M>>,
readonly children: DOMView<T, M>[]
) {}
mount(
state: T,
append: (node: Node) => void,
dispatch: (message: M) => void
): MountedView<T> {
const { element, attributes, events } = Object.keys(this.attributes).reduce(
(acc: { element: HTMLElement, attributes: DDynamicAttribute<T, any>[], events: DEventObject<T, M>[] }, key: string) => {
const val = this.attributes[key]
if (typeof val === 'function') {
if (key.startsWith('on')) {
acc.events.push({
event: key,
handler: val
})
} else {
acc.attributes.push({
attribute: key,
valuef: val
})
}
} else if (typeof val !== 'undefined') {
acc.element.setAttribute(key, val)
}
return acc
},
{
element: document.createElement(this.name),
attributes: [],
events: []
}
)
const mountedChildren = this.children.map(child => child.mount(state, node => element.appendChild(node), dispatch))
const mounted = new DMountedView(
element,
attributes,
events,
mountedChildren,
dispatch
)
append(element)
mounted.render(state)
return mounted
}
}
export class DMountedView<T, M> {
constructor(
readonly element: HTMLElement,
readonly attributes: DDynamicAttribute<T, any>[],
readonly events: DEventObject<T, M>[],
readonly mountedChildren: MountedView<T>[],
readonly dispatch: (message: M) => void
) {}
unmount(): void {
removeNode(this.element)
}
render(state: T): void {
this.attributes.forEach(att => {
const val = att.valuef(state)
if (typeof val === 'undefined')
this.element.removeAttribute(att.attribute)
else
this.element.setAttribute(att.attribute, val)
})
this.events.forEach(event => {
const handler = event.handler(state)
if (typeof handler === 'undefined') {
this.element[event.event] = undefined
} else {
this.element[event.event] = (e: any) => {
const message = handler(e)
if (typeof message !== 'undefined')
this.dispatch(message)
}
}
})
this.mountedChildren.forEach(m => m.render(state))
}
}
export class DText<T> {
constructor(
readonly text: DAttribute<T, any>
) {}
mount(
state: T,
append: (node: Node) => void,
dispatch: (_: any) => void
): MountedView<T> {
const node = document.createTextNode('')
const mounted = new DMountedText(node, this.text)
append(node)
mounted.render(state)
return mounted
}
}
export class DMountedText<T> {
constructor(
readonly node: Text,
readonly content: DAttribute<T, any>
) {}
unmount(): void {
removeNode(this.node)
}
render(state: T): void {
const value = this.content(state)
if (this.node.textContent !== value)
this.node.textContent = value
}
}
export class MapDownElement<A, B, M> {
constructor(
readonly map: (value: A) => B,
readonly children: DOMView<B, M>[]
) {}
mount(
state: A,
append: (node: Node) => void,
dispatch: (message: M) => void
): MountedView<A> {
const b = this.map(state)
const mountedChildren = this.children.map(child => child.mount(b, append, dispatch))
return new MapDownMountedView(
this.map,
mountedChildren
)
}
}
export class MapDownMountedView<A, B, M> {
constructor(
readonly map: (value: A) => B,
readonly mountedChildren: MountedView<B>[]
) {}
unmount(): void {
this.mountedChildren.forEach(m => m.unmount())
}
render(state: A): void {
const b = this.map(state)
this.mountedChildren.forEach(m => m.render(b))
}
}
export class MapUpElement<T, A, B> {
constructor(
readonly map: (value: B) => A | undefined,
readonly children: DOMView<T, B>[]
) {}
mount(
state: T,
append: (node: Node) => void,
dispatch: (message: A) => void
): MountedView<T> {
const dispatchUp = (b: B) => {
const a = this.map(b)
if (a !== void 0)
dispatch(a)
}
const mountedChildren = this.children.map(child => child.mount(state, append, dispatchUp))
return new MapUpMountedView<T, B>(mountedChildren)
}
}
export class MapUpMountedView<T, M> {
constructor(
readonly mountedChildren: MountedView<T>[]
) {}
unmount(): void {
this.mountedChildren.forEach(m => m.unmount())
}
render(state: T): void {
this.mountedChildren.forEach(m => m.render(state))
}
}
export class WhenElement<T, M> {
constructor(
readonly condition: (value: T) => boolean,
readonly positive: DOMView<T, M>[],
readonly negative: DOMView<T, M>[]
) {}
mount(
state: T,
append: (node: Node) => void,
dispatch: (message: M) => void
): MountedView<T> {
const mounted = new MountedWhenElement<T, M>(
this.condition,
this.positive,
this.negative,
append,
dispatch
)
mounted.render(state)
return mounted
}
}
enum WhenMounted {
Positive = 'positive',
Negative = 'negative',
Neither = 'neither'
}
export class MountedWhenElement<T, M> {
private currentlyMounted: DOMMountedView<T>[] = []
private mounted = WhenMounted.Neither
constructor(
readonly condition: (value: T) => boolean,
readonly positive: DOMView<T, M>[],
readonly negative: DOMView<T, M>[],
readonly append: (node: Node) => void,
readonly dispatch: (message: M) => void
) {}
unmount(): void {
this.currentlyMounted.forEach(cur => cur.unmount())
}
render(state: T): void {
const cond = this.condition(state)
if (cond) {
if (this.mounted === WhenMounted.Neither) {
this.mounted = WhenMounted.Positive
this.currentlyMounted = this.positive.map(child => child.mount(state, this.append, this.dispatch))
} else if (this.mounted === WhenMounted.Positive) {
this.currentlyMounted.forEach(cur => cur.render(state))
} else {
this.mounted = WhenMounted.Positive
this.currentlyMounted.forEach(cur => cur.unmount())
this.currentlyMounted = this.positive.map(child => child.mount(state, this.append, this.dispatch))
}
} else {
if (this.mounted === WhenMounted.Neither) {
this.mounted = WhenMounted.Negative
this.currentlyMounted = this.negative.map(child => child.mount(state, this.append, this.dispatch))
} else if (this.mounted === WhenMounted.Positive) {
this.mounted = WhenMounted.Negative
this.currentlyMounted.forEach(cur => cur.unmount())
this.currentlyMounted = this.negative.map(child => child.mount(state, this.append, this.dispatch))
} else {
this.currentlyMounted.forEach(cur => cur.render(state))
}
}
}
}
export class RepeatElement<E, T extends E[], M> {
constructor(
readonly element: DOMView<E, M>[]
) {}
mount(
state: T,
append: (node: Node) => void,
dispatch: (message: M) => void
): MountedView<T> {
const mounted = new MountedRepeatElement<E, T, M>(
this.element,
append,
dispatch
)
mounted.render(state)
return mounted
}
}
export class MountedRepeatElement<E, T extends E[], M> {
private mountedChildren: DOMMountedView<E>[][] = []
constructor(
readonly element: DOMView<E, M>[],
readonly append: (node: Node) => void,
readonly dispatch: (message: M) => void
) {}
unmount(): void {
this.mountedChildren.forEach(m => m.forEach(v => v.unmount()))
}
render(state: T): void {
const stateLength = state.length
const mountedLength = this.mountedChildren.length
if (stateLength > mountedLength) {
for (let i = 0; i < mountedLength; i++) {
const val = state[i]
this.mountedChildren[i].forEach(child => child.render(val))
}
for (let i = mountedLength; i < stateLength; i++) {
const val = state[i]
this.mountedChildren.push(this.element.map(el => el.mount(val, this.append, this.dispatch)))
}
} else {
for (let i = 0; i < stateLength; i++) {
const val = state[i]
this.mountedChildren[i].forEach(child => child.render(val))
}
for (let i = stateLength; i < mountedLength; i++) {
this.mountedChildren[i].forEach(child => child.unmount())
}
this.mountedChildren = this.mountedChildren.slice(0, stateLength)
}
}
}
export const repeat = <E, T extends E[], M>(
...children: DOMChild<E, M>[]
) => new RepeatElement(children.map(domChildToView))
export const when = <T, M>(
opts: { condition: (value: T) => boolean },
positive: DOMChild<T, M>[],
negative?: DOMChild<T, M>[]
) => new WhenElement(opts.condition, positive.map(domChildToView), (negative || []).map(domChildToView))
export const down = <A, B, M>(
opts: { map: (value: A) => B },
...children: DOMChild<B, M>[]
): DOMView<A, M> => new MapDownElement(opts.map, children.map(domChildToView))
export const up = <T, A, B>(
opts: { map: (value: B) => A | undefined },
...children: DOMChild<T, B>[]
): DOMView<T, A> => new MapUpElement(opts.map, children.map(domChildToView))
export const el = <T, M>(
name: string,
attributes: Record<string, DAttribute<T, any> | DEvent<T, M>>,
children: DOMChild<T, M>[]
): DOMView<T, M> => {
return new DElement(name, attributes || {}, (children || []).map(domChildToView))
}
export const div = <T, M>(
attributes: Record<string, DAttribute<T, any> | DEvent<T, M>>,
...children: DOMChild<T, M>[]
): DOMView<T, M> => el('div', attributes, children)
export const span = <T, M>(
attributes: Record<string, DAttribute<T, any> | DEvent<T, M>>,
...children: DOMChild<T, M>[]
): DOMView<T, M> => el('span', attributes, children)
export const button = <T, M>(
attributes: Record<string, DAttribute<T, any> | DEvent<T, M>>,
...children: DOMChild<T, M>[]
): DOMView<T, M> => el('button', attributes, children)
export const Spoon = {
mount<T, M>(
el: HTMLElement,
description: View<T, M, Node>,
state: T,
update: (state: T, message: M, dispatch: (message: M) => void) => T
) {
function dispatch(message: M) {
state = update(state, message, dispatch)
view.render(state)
}
const view = description.mount(
state,
(node: Node) => {
el.appendChild(node)
},
dispatch
)
return {
unmount() {
view.unmount()
},
dispatch(state: T) {
view.render(state)
}
}
}
}
const text = <T>(content: string | ((state: T) => string)): DOMView<T, any> => new DText(typeof content === 'string' ? (_: T) => content : content)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment