aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/lustre.ffi.mjs129
-rw-r--r--src/lustre.gleam12
-rw-r--r--src/lustre/event.gleam16
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 ----------------------------------------------------------------
///