Last active
July 30, 2018 01:29
-
-
Save fponticelli/78db1cf2a0daf830e34989d393be70bd to your computer and use it in GitHub Desktop.
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 { 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 } | |
} | |
} | |
) | |
}) |
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
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