The Little Web Framework That Could

I recently added seasonal animations to this website using WebGL. Right now, I only have an animation for autumn, but in the future I want to add more for the other seasons. Additionally, I wanted the animations to be opt-in, since motion can be distracting for some users. Finally, I wanted the option to persist to localStorage to be saved between page loads.

The layout isn’t super complicated, essentially I have a full-screen fixed div at a z-index below the main content that may or may not contain a seasonal animation. The select list that manages which animation to display lives in a separate fixed div that’s pinned to the bottom-right of the screen. Thus, we have two separate “components” that want access to the shared state of the currently selected animation, which also needs to be persisted to localStorage after every change.

This is what I’ve come up with:

interface ObRO<T> {
    readonly value: T,
    watch(cb: (v: T) => void): void,
    watchInit(cb: (v: T) => void): void,
}

interface Ob<T> extends ObRO<T> {
    ro(): ObRO<T>,
    set(newVal: T): void,
}

function ob<T>(v: T): Ob<T> {
    let onSet = (_: T) => {};

    const self = {
        value: v,
        ro() { return <ObRO<T>>self },
        set(newVal: T) {
            self.value = newVal;
            onSet(self.value);
        },
        watch(cb: (v: T) => void) {
            const o = onSet;
            onSet = (v: T) => {
                cb(v);
                o(v);
            };
        },
        watchInit(cb: (v: T) => void) {
            self.watch(cb);
            cb(v);
        },
    };

    return self;
}

Essentially, it lets you register listeners for a piece of data, and whenever that data is set, each listener is called with the new value.

Here’s the code that persists the season changes to localStorage:

const season: Ob<Season> = ob(stored('season') || 'off');
season.watch((v: Season) => {
    setStored('season', v);
});

And here’s the code that sets the animation container content when the season changes:

let destroy = () => {};
const initSeason = (v: Season) => {
    destroy();
    destroy = () => {};

    requestIdleCallback(() => {
        switch (v) {
        case 'autumn': {
            const res = autumn();
            if (!res) {
                return;
            }

            const [el, d] = res;
            seasonContainer.replaceChildren(el);
            destroy = d;
            break;
        }
        case 'off': { seasonContainer.replaceChildren(); break; }
        default: { const c: never = v; throw (c); };
        }
    }, { timeout: 500 });
}
season.watchInit(initSeason);

This little bit of code gets me 90% of the value of something like React would give me. However, DOM manipulations do have to be done manually, whereas React has their vDOM reconciliation and whatnot. For my use case, this is an acceptable trade-off. Will this scale to a whole SPA? I don’t know, I’ll let you know when I try it.

Finally, to complement this, I have a small utility function I use to create DOM elements:

interface ElOptions<Kind extends HTMLElement> {
    attrs?: Record<string, string>,
    children?: Node[],
    events?: Partial<Record<keyof HTMLElementEventMap, EventListener>>,
    callback?: (el: Kind) => any,
};

function el<Kind extends HTMLElement>(
    kind: keyof HTMLElementTagNameMap,
    options?: ElOptions<Kind>,
): Kind {
    const e = <Kind>document.createElement(kind);
    for (const k in options?.attrs) {
        e.setAttribute(k, options!.attrs[k]);
    }
    for (const k in options?.events) {
        const k_ = <keyof HTMLElementEventMap>k;
        e.addEventListener(k_, <any>options!.events[k_]);
    }

    e.append(...(options?.children || []));

    if (options?.callback) {
        options.callback(e);
    }

    return e;
}

Here’s an example usage:

function seasonButton(season: Ob<Season>): HTMLElement {
    const choices: (Season)[] = ['autumn', 'off'];
    const select: HTMLSelectElement = el('select', {
        events: { change: (ev) => {
            const [selected] = (<HTMLSelectElement>ev.target).selectedOptions;
            const value = <Season>selected.value;
            season.set(value);
        } },
        children: choices.map(c => el('option', {
            attrs: { value: c, },
            children: [document.createTextNode(c)],
        })),
        callback: (e) => {
            e.selectedIndex = choices.indexOf(season.value);
        },
    });

    const div = el('div', { children: [
        document.createTextNode('Season: '),
        select,
    ]});

    return div;
}

This is just enough framework ™ for what I’m doing currently. All of the source code should be accessible via your browser’s dev tools if you’re curious to see more.

Published on 2021-10-29