diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2023-07-19 22:03:41 +0100 |
---|---|---|
committer | Hayleigh Thompson <me@hayleigh.dev> | 2023-07-19 22:03:41 +0100 |
commit | be928f231f8fbf716593cde1a80387b8539d4635 (patch) | |
tree | 3df6a79456a6c2e96e9043f578eb858681bdaa35 | |
parent | 2950d216902d3b4b21e8421ae390f63d1ea8ea7b (diff) | |
download | lustre-be928f231f8fbf716593cde1a80387b8539d4635.tar.gz lustre-be928f231f8fbf716593cde1a80387b8539d4635.zip |
:sparkles: Add support for nested lustre apps as custom web components.
-rw-r--r-- | src/lustre.ffi.mjs | 129 | ||||
-rw-r--r-- | src/lustre.gleam | 12 | ||||
-rw-r--r-- | src/lustre/event.gleam | 16 |
3 files changed, 141 insertions, 16 deletions
diff --git a/src/lustre.ffi.mjs b/src/lustre.ffi.mjs index 59fe49b..25d82ae 100644 --- a/src/lustre.ffi.mjs +++ b/src/lustre.ffi.mjs @@ -1,7 +1,8 @@ -import { morph } from "./runtime.ffi.mjs"; -import { Ok, Error } from "./gleam.mjs"; -import { ElementNotFound } from "./lustre.mjs"; +import { ElementNotFound, ComponentAlreadyRegistered } from "./lustre.mjs"; +import { from } from "./lustre/effect.mjs"; import { map } from "./lustre/element.mjs"; +import { morph } from "./runtime.ffi.mjs"; +import { Ok, Error, isEqual } from "./gleam.mjs"; // RUNTIME --------------------------------------------------------------------- @@ -9,9 +10,10 @@ import { map } from "./lustre/element.mjs"; /// export class App { #root = null; + #el = null; #state = null; #queue = []; - #commands = []; + #effects = []; #willUpdate = false; #didUpdate = false; @@ -29,12 +31,15 @@ export class App { if (this.#root) return this; try { - const el = document.querySelector(selector); - const [next, cmds] = this.#init(); + const el = + selector instanceof HTMLElement + ? selector + : document.querySelector(selector); + const [next, effects] = this.#init(); this.#root = el; this.#state = next; - this.#commands = cmds[0].toArray(); + this.#effects = effects[0].toArray(); this.#didUpdate = true; window.requestAnimationFrame(() => this.#tick()); @@ -52,11 +57,33 @@ export class App { this.#willUpdate = true; } + emit(name, event = null) { + this.#root.dispatchEvent( + new CustomEvent(name, { + bubbles: true, + detail: event, + composed: true, + }) + ); + } + + destroy() { + this.#root = null; + this.#el.remove(); + this.#state = null; + this.#queue = []; + this.#effects = []; + this.#willUpdate = false; + this.#didUpdate = false; + this.#update = () => {}; + this.#view = () => {}; + } + #render() { const node = this.#view(this.#state); const vdom = map(node, (msg) => this.dispatch(msg)); - morph(this.#root, vdom); + this.#el = morph(this.#root, vdom); } #tick() { @@ -69,19 +96,21 @@ export class App { #flush(times = 0) { if (this.#queue.length) { while (this.#queue.length) { - const [next, cmds] = this.#update(this.#state, this.#queue.shift()); + const [next, effects] = this.#update(this.#state, this.#queue.shift()); this.#state = next; - this.#commands.concat(cmds[0].toArray()); + this.#effects = this.#effects.concat(effects[0].toArray()); } - this.#didUpdate = true; } - // Each update can produce commands which must now be executed. - while (this.#commands.length) this.#commands.shift()(this.dispatch); + // Each update can produce effects which must now be executed. + while (this.#effects[0]) + this.#effects.shift()(this.dispatch, (name, data) => + this.emit(name, data) + ); - // Synchronous commands will immediately queue a message to be processed. If + // Synchronous effects will immediately queue a message to be processed. If // it is reasonable, we can process those updates too before proceeding to // the next render. if (this.#queue.length) { @@ -92,3 +121,75 @@ export class App { export const setup = (init, update, render) => new App(init, update, render); export const start = (app, selector) => app.start(selector); + +export const emit = (name, data) => + // Normal `Effect`s constructed in Gleam from `effect.from` don't get told + // about the second argument, but it's there 👀. + from((_, emit) => { + emit(name, data); + }); + +// CUSTOM ELEMENTS ------------------------------------------------------------- + +export const setup_component = ( + name, + init, + update, + render, + on_attribute_change +) => { + if (customElements.get(name)) { + return new Error(new ComponentAlreadyRegistered()); + } + + customElements.define( + name, + class extends HTMLElement { + #container = document.createElement("div"); + #app = null; + #dispatch = null; + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.#container); + + this.#app = new App(init, update, render); + const dispatch = this.#app.start(this.#container); + this.#dispatch = dispatch[0]; + + on_attribute_change.forEach((decoder, name) => { + Object.defineProperty(this, name, { + get: () => { + return this[`_${name}`] || this.getAttribute(name); + }, + + set: (value) => { + const prev = this[name]; + const decoded = decoder(value); + + // We need this equality check to prevent constantly dispatching + // messages when the value is an object or array: it might not have + // changed but its reference might have and we don't want to trigger + // useless updates. + if (decoded.isOk() && !isEqual(prev, decoded[0])) { + this.#dispatch(decoded[0]); + } + + if (typeof value === "string") { + this.setAttribute(name, value); + } else { + this[`_${name}`] = value; + } + }, + }); + }); + } + + disconnectedCallback() { + this.#app.destroy(); + } + } + ); + return new Ok(null); +}; diff --git a/src/lustre.gleam b/src/lustre.gleam index bbeb39b..4998a50 100644 --- a/src/lustre.gleam +++ b/src/lustre.gleam @@ -2,6 +2,8 @@ // IMPORTS --------------------------------------------------------------------- +import gleam/dynamic.{Decoder} +import gleam/map.{Map} import lustre/effect.{Effect} import lustre/element.{Element} @@ -49,6 +51,7 @@ pub type App(model, msg) pub type Error { ElementNotFound + ComponentAlreadyRegistered } // These types aren't exposed, but they're just here to try and shrink the type @@ -204,6 +207,15 @@ pub fn application(init: fn() -> #(model, Effect(msg)), update: Update( msg, ), render: Render(model, msg)) -> App(model, msg) +@external(javascript, "./lustre.ffi.mjs", "setup_component") +pub fn component(name: String, init: fn() -> #(model, Effect(msg)), update: Update( + model, + msg, + ), render: Render(model, msg), on_attribute_change: Map(String, Decoder(msg))) -> Result( + Nil, + Error, +) + // EFFECTS --------------------------------------------------------------------- /// Once you have created a app with either `basic` or `application`, you diff --git a/src/lustre/event.gleam b/src/lustre/event.gleam index 95de6c9..9b7e86b 100644 --- a/src/lustre/event.gleam +++ b/src/lustre/event.gleam @@ -3,15 +3,27 @@ // IMPORTS --------------------------------------------------------------------- import gleam/dynamic.{DecodeError, Dynamic} -import gleam/option.{None, Some} +import gleam/option.{None, Option, Some} import gleam/result -import lustre/attribute.{Attribute, on} +import lustre/attribute.{Attribute} +import lustre/effect.{Effect} // TYPES ----------------------------------------------------------------------- type Decoded(a) = Result(a, List(DecodeError)) +// EFFECTS --------------------------------------------------------------------- + +@external(javascript, "../lustre.ffi.mjs", "emit") +pub fn emit(event: String, data: any) -> Effect(msg) + +// CUSTOM EVENTS --------------------------------------------------------------- + +pub fn on(name: String, handler: fn(Dynamic) -> Option(msg)) -> Attribute(msg) { + attribute.on(name, handler) +} + // MOUSE EVENTS ---------------------------------------------------------------- /// |