Packages
lustre
5.3.1
5.7.1
5.7.0
5.6.0
5.5.2
5.5.1
5.5.0
5.4.0
5.3.5
5.3.4
5.3.3
5.3.2
5.3.1
5.3.0
5.2.1
5.2.0
5.1.1
5.1.0
5.0.3
5.0.2
5.0.1
5.0.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.1
4.5.0
4.4.4
4.4.3
4.4.1
4.4.0
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.8
4.1.7
4.1.6
4.1.5
4.1.4
4.1.3
4.1.2
4.1.1
4.1.0
4.0.0
4.0.0-rc1
4.0.0-rc.2
3.1.4
3.1.3
3.1.2
3.1.1
3.1.0
3.0.12
3.0.11
3.0.10
3.0.9
3.0.8
3.0.7
3.0.6
3.0.5
3.0.4
3.0.3
3.0.2
3.0.1
3.0.0
3.0.0-rc.8
3.0.0-rc.7
3.0.0-rc.6
3.0.0-rc.5
3.0.0-rc.4
3.0.0-rc.3
3.0.0-rc.2
3.0.0-rc.1
2.0.1
2.0.0
1.3.0
1.2.0
1.1.0
1.0.0
Create HTML templates, single page applications, Web Components, and real-time server components in Gleam!
Current section
Files
Jump to
Current section
Files
src/lustre/runtime/client/component.ffi.mjs
// IMPORTS ---------------------------------------------------------------------
import { Ok, Error } from "../../../gleam.mjs";
import { run as decode } from "../../../../gleam_stdlib/gleam/dynamic/decode.mjs";
import { Some } from "../../../../gleam_stdlib/gleam/option.mjs";
import {
BadComponentName,
ComponentAlreadyRegistered,
NotABrowser,
is_browser,
} from "../../../lustre.mjs";
import {
Runtime,
ContextRequestEvent,
adoptStylesheets,
} from "./runtime.ffi.mjs";
import {
EffectDispatchedMessage,
EffectEmitEvent,
SystemRequestedShutdown,
} from "../server/runtime.mjs";
//
export const make_component = ({ init, update, view, config }, name) => {
if (!is_browser()) return new Error(new NotABrowser());
if (!name.includes("-")) return new Error(new BadComponentName(name));
if (customElements.get(name)) {
return new Error(new ComponentAlreadyRegistered(name));
}
const attributes = new Map();
const observedAttributes = [];
for (let attr = config.attributes; attr.tail; attr = attr.tail) {
const [name, decoder] = attr.head;
if (attributes.has(name)) continue;
attributes.set(name, decoder);
observedAttributes.push(name);
}
const [model, effects] = init(undefined);
const component = class Component extends HTMLElement {
static get observedAttributes() {
return observedAttributes;
}
static formAssociated = config.is_form_associated;
#runtime;
#adoptedStyleNodes = [];
#shadowRoot;
#contextSubscriptions = new Map();
constructor() {
super();
// a shadow root may have already been constructed through declarative
this.internals = this.attachInternals();
// shadow root elements.
if (!this.internals.shadowRoot) {
this.#shadowRoot = this.attachShadow({
mode: config.open_shadow_root ? "open" : "closed",
delegatesFocus: config.delegates_focus,
});
} else {
this.#shadowRoot = this.internals.shadowRoot;
}
if (config.adopt_styles) {
this.#adoptStyleSheets();
}
this.#runtime = new Runtime(
this.#shadowRoot,
[model, effects],
view,
update,
);
}
// CUSTOM ELEMENT LIFECYCLE METHODS ----------------------------------------
connectedCallback() {
// Keep track of the requested contexts so we don't request the same one
// twice.
const requested = new Set();
for (let ctx = config.contexts; ctx.tail; ctx = ctx.tail) {
const [key, decoder] = ctx.head;
// An empty key is not valid so we skip over any of those.
if (!key) continue;
// Likewise if we've requested a context for this key already then we
// don't want to dispatch a second event, even if the user provided a
// different decoder.
if (requested.has(key)) continue;
this.dispatchEvent(
new ContextRequestEvent(
key,
(value, unsubscribe) => {
const previousUnsubscribe = this.#contextSubscriptions.get(key);
// Call the old unsubscribe callback if it has changed. This probably
// means we have a new provider.
if (previousUnsubscribe !== unsubscribe) {
previousUnsubscribe?.();
}
const decoded = decode(value, decoder);
this.#contextSubscriptions.set(key, unsubscribe);
if (decoded.isOk()) {
this.dispatch(decoded[0]);
}
},
true,
),
);
requested.add(key);
}
}
adoptedCallback() {
if (config.adopt_styles) {
this.#adoptStyleSheets();
}
}
attributeChangedCallback(name, _, value) {
const decoded = attributes.get(name)(value ?? "");
if (decoded.isOk()) {
this.dispatch(decoded[0]);
}
}
formResetCallback() {
if (config.on_form_reset instanceof Some) {
this.dispatch(config.on_form_reset[0]);
}
}
formStateRestoreCallback(state, reason) {
switch (reason) {
case "restore":
if (config.on_form_restore instanceof Some) {
this.dispatch(config.on_form_restore[0](state));
}
break;
case "autocomplete":
if (config.on_form_populate instanceof Some) {
this.dispatch(config.on_form_autofill[0](state));
}
break;
}
}
disconnectedCallback() {
for (const [_, unsubscribe] of this.#contextSubscriptions) {
unsubscribe?.();
}
this.#contextSubscriptions.clear();
}
// LUSTRE RUNTIME METHODS --------------------------------------------------
send(message) {
switch (message.constructor) {
case EffectDispatchedMessage: {
this.dispatch(message.message, false);
break;
}
case EffectEmitEvent: {
this.emit(message.name, message.data);
break;
}
case SystemRequestedShutdown:
//TODO
break;
}
}
dispatch(msg, immediate = false) {
this.#runtime.dispatch(msg, immediate);
}
emit(event, data) {
this.#runtime.emit(event, data);
}
provide(key, value) {
this.#runtime.provide(key, value);
}
async #adoptStyleSheets() {
while (this.#adoptedStyleNodes.length) {
this.#adoptedStyleNodes.pop().remove();
this.shadowRoot.firstChild.remove();
}
this.#adoptedStyleNodes = await adoptStylesheets(this.#shadowRoot);
}
};
for (let prop = config.properties; prop.tail; prop = prop.tail) {
const [name, decoder] = prop.head;
if (Object.hasOwn(component.prototype, name)) {
continue;
}
Object.defineProperty(component.prototype, name, {
get() {
return this[`_${name}`];
},
set(value) {
this[`_${name}`] = value;
const decoded = decode(value, decoder);
if (decoded.constructor === Ok) {
this.dispatch(decoded[0]);
}
},
});
}
customElements.define(name, component);
return new Ok(undefined);
};
//
export const set_form_value = (root, value) => {
if (!is_browser()) return;
if (root instanceof ShadowRoot) {
root.host.internals.setFormValue(value);
}
};
export const clear_form_value = (internals) => {
if (!is_browser()) return;
if (root instanceof ShadowRoot) {
root.host.internals.setFormValue(undefined);
}
};
export const set_pseudo_state = (root, value) => {
if (!is_browser()) return;
if (root instanceof ShadowRoot) {
root.host.internals.states.add(value);
}
};
export const remove_pseudo_state = (root, value) => {
if (!is_browser()) return;
if (root instanceof ShadowRoot) {
root.host.internals.states.delete(value);
}
};