Name | SSE |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | |
Path | official/backend/actions/sse.ts |
Source | import { DATASTAR, DATASTAR_REQUEST } from "../../../../engine/consts"; import { dsErr } from "../../../../engine/errors"; import { ActionPlugin, PluginType } from "../../../../engine/types"; import { fetchEventSource, FetchEventSourceInit, } from "../../../../vendored/fetch-event-source"; import { DATASTAR_SSE_EVENT, DatastarSSEEvent, FINISHED, STARTED, } from "../shared"; type METHOD = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; function dispatchSSE(type: string, argsRaw: Record<string, string>) { document.dispatchEvent( new CustomEvent<DatastarSSEEvent>(DATASTAR_SSE_EVENT, { detail: { type, argsRaw }, }), ); } const isWrongContent = (err: any) => `${err}`.includes(`text/event-stream`); export type SSEArgs = { method: METHOD; headers?: Record<string, string>; includeLocal?: boolean; openWhenHidden?: boolean; retryScaler?: number; retryMaxWaitMs?: number; retryMaxCount?: number; abort?: AbortSignal; }; export const SSE: ActionPlugin = { type: PluginType.Action, name: "sse", fn: async ( ctx, url: string, args: SSEArgs, ) => { const { el: { id: elId }, signals } = ctx; const { method: methodAnyCase, headers: userHeaders, includeLocal, openWhenHidden, retryScaler, retryMaxWaitMs, retryMaxCount, abort, } = Object .assign({ method: "GET", headers: {}, includeLocal: false, openWhenHidden: false, // will keep the request open even if the document is hidden. retryScaler: 2, // the amount to multiply the retry interval by each time retryMaxWaitMs: 30_000, // the maximum retry interval in milliseconds retryMaxCount: 10, // the maximum number of retries before giving up abort: undefined, }, args); const method = methodAnyCase.toUpperCase(); try { dispatchSSE(STARTED, { elId }); if (!!!url?.length) { throw dsErr("NoUrlProvided"); } const headers = Object.assign({ "Content-Type": "application/json", [DATASTAR_REQUEST]: true, }, userHeaders); const req: FetchEventSourceInit = { method, headers, openWhenHidden, retryScaler, retryMaxWaitMs, retryMaxCount, signal: abort, onmessage: (evt) => { if (!evt.event.startsWith(DATASTAR)) { return; } const type = evt.event; const argsRawLines: Record<string, string[]> = {}; const lines = evt.data.split("\n"); for (const line of lines) { const colonIndex = line.indexOf(" "); const key = line.slice(0, colonIndex); let argLines = argsRawLines[key]; if (!argLines) { argLines = []; argsRawLines[key] = argLines; } const value = line.slice(colonIndex + 1).trim(); argLines.push(value); } const argsRaw: Record<string, string> = {}; for (const [key, lines] of Object.entries(argsRawLines)) { argsRaw[key] = lines.join("\n"); } // if you aren't seeing your event you can debug by using this line in the console // document.addEventListener("datastar-sse",(e) => console.log(e)); dispatchSSE(type, argsRaw); }, onerror: (error) => { if (isWrongContent(error)) { // don't retry if the content-type is wrong throw dsErr("InvalidContentType", { url, error }); } // do nothing and it will retry if (error) { console.error(error.message); } }, }; const urlInstance = new URL(url, window.location.origin); const json = signals.JSON(false, !includeLocal); if (method === "GET") { const queryParams = new URLSearchParams(urlInstance.search); queryParams.set(DATASTAR, json); urlInstance.search = queryParams.toString(); } else { req.body = json; } try { await fetchEventSource(urlInstance.toString(), req); } catch (error) { if (!isWrongContent(error)) { throw dsErr("SseFetchFailed", { method, url, error }); } // exit gracefully and do nothing if the content-type is wrong // this can happen if the client is sending a request // where no response is expected, and they haven't // set the content-type to text/event-stream } } finally { dispatchSSE(FINISHED, { elId }); } }, }; |
Name | Indicator |
---|---|
Description | must be a valid signal name |
Authors | |
Path | official/backend/attributes/indicator.ts |
Source | import { DATASTAR } from "../../../../engine/consts"; import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; import { DATASTAR_SSE_EVENT, DatastarSSEEvent, FINISHED, STARTED, } from "../shared"; export const INDICATOR_CLASS = `${DATASTAR}-indicator`; export const INDICATOR_LOADING_CLASS = `${INDICATOR_CLASS}-loading`; export const Indicator: AttributePlugin = { type: PluginType.Attribute, name: "indicator", keyReq: Requirement.Exclusive, valReq: Requirement.Exclusive, onLoad: ({ value, signals, el, key }) => { const signalName = !!key ? key : value; const signal = signals.upsert(signalName, false); const watcher = (event: CustomEvent<DatastarSSEEvent>) => { const { type, argsRaw: { elId }, } = event.detail; if (elId !== el.id) return; switch (type) { case STARTED: signal.value = true; break; case FINISHED: signal.value = false; break; } }; document.addEventListener(DATASTAR_SSE_EVENT, watcher); return () => { document.removeEventListener(DATASTAR_SSE_EVENT, watcher); }; }, }; |
Name | ExecuteScript |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | |
Path | official/backend/watchers/executeScript.ts |
Source | import { DefaultExecuteScriptAttributes, DefaultExecuteScriptAutoRemove, EventTypes, } from "../../../../engine/consts"; import { dsErr } from "../../../../engine/errors"; import { PluginType, WatcherPlugin } from "../../../../engine/types"; import { isBoolString } from "../../../../utils/text"; import { datastarSSEEventWatcher } from "../shared"; export const ExecuteScript: WatcherPlugin = { type: PluginType.Watcher, name: EventTypes.ExecuteScript, onGlobalInit: async () => { datastarSSEEventWatcher( EventTypes.ExecuteScript, ({ autoRemove: autoRemoveRaw = `${DefaultExecuteScriptAutoRemove}`, attributes: attributesRaw = DefaultExecuteScriptAttributes, script, }) => { const autoRemove = isBoolString(autoRemoveRaw); if (!script?.length) { throw dsErr("NoScriptProvided"); } const scriptEl = document.createElement("script"); attributesRaw.split("\n").forEach((attr) => { const pivot = attr.indexOf(" "); const key = pivot ? attr.slice(0, pivot) : attr; const value = pivot ? attr.slice(pivot) : ""; scriptEl.setAttribute(key.trim(), value.trim()); }); scriptEl.text = script; document.head.appendChild(scriptEl); if (autoRemove) { scriptEl.remove(); } }, ); }, }; |
Name | MergeFragments |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | |
Path | official/backend/watchers/mergeFragments.ts |
Source | import { DefaultFragmentMergeMode, DefaultFragmentsSettleDurationMs, DefaultFragmentsUseViewTransitions, EventTypes, FragmentMergeModes, } from "../../../../engine/consts"; import { dsErr } from "../../../../engine/errors"; import { InitContext, PluginType, WatcherPlugin, } from "../../../../engine/types"; import { isBoolString } from "../../../../utils/text"; import { docWithViewTransitionAPI, supportsViewTransitions, } from "../../../../utils/view-transtions"; import { idiomorph } from "../../../../vendored/idiomorph"; import { datastarSSEEventWatcher, SETTLING_CLASS, SWAPPING_CLASS, } from "../shared"; export const MergeFragments: WatcherPlugin = { type: PluginType.Watcher, name: EventTypes.MergeFragments, onGlobalInit: async (ctx) => { const fragmentContainer = document.createElement("template"); datastarSSEEventWatcher( EventTypes.MergeFragments, ({ fragments: fragmentsRaw = "<div></div>", selector = "", mergeMode = DefaultFragmentMergeMode, settleDuration: settleDurationRaw = `${DefaultFragmentsSettleDurationMs}`, useViewTransition: useViewTransitionRaw = `${DefaultFragmentsUseViewTransitions}`, }) => { const settleDuration = parseInt(settleDurationRaw); const useViewTransition = isBoolString(useViewTransitionRaw); fragmentContainer.innerHTML = fragmentsRaw.trim(); const fragments = [...fragmentContainer.content.children]; fragments.forEach((fragment) => { if (!(fragment instanceof Element)) { throw dsErr("NoFragmentsFound"); } const selectorOrID = selector || `#${fragment.getAttribute("id")}`; const targets = [ ...document.querySelectorAll(selectorOrID) || [], ]; if (!targets.length) { throw dsErr("NoTargetsFound", { selectorOrID }); } if (supportsViewTransitions && useViewTransition) { docWithViewTransitionAPI.startViewTransition(() => applyToTargets( ctx, mergeMode, settleDuration, fragment, targets, ) ); } else { applyToTargets( ctx, mergeMode, settleDuration, fragment, targets, ); } }); }, ); }, }; function applyToTargets( ctx: InitContext, mergeMode: string, settleDuration: number, fragment: Element, capturedTargets: Element[], ) { for (const initialTarget of capturedTargets) { initialTarget.classList.add(SWAPPING_CLASS); const originalHTML = initialTarget.outerHTML; let modifiedTarget = initialTarget; switch (mergeMode) { case FragmentMergeModes.Morph: const result = idiomorph(modifiedTarget, fragment, { callbacks: { beforeNodeRemoved: (oldNode: Element, _: Element) => { ctx.cleanup(oldNode); return true; }, }, }); if (!result?.length) { throw dsErr("MorphFailed"); } modifiedTarget = result[0] as Element; break; case FragmentMergeModes.Inner: // Replace the contents of the target element with the response modifiedTarget.innerHTML = fragment.innerHTML; break; case FragmentMergeModes.Outer: // Replace the entire target element with the response modifiedTarget.replaceWith(fragment); break; case FragmentMergeModes.Prepend: // Insert the response before the first child of the target element modifiedTarget.prepend(fragment); break; case FragmentMergeModes.Append: // Insert the response after the last child of the target element modifiedTarget.append(fragment); break; case FragmentMergeModes.Before: // Insert the response before the target element modifiedTarget.before(fragment); break; case FragmentMergeModes.After: // Insert the response after the target element modifiedTarget.after(fragment); break; case FragmentMergeModes.UpsertAttributes: // Upsert the attributes of the target element fragment.getAttributeNames().forEach((attrName) => { const value = fragment.getAttribute(attrName)!; modifiedTarget.setAttribute(attrName, value); }); break; default: throw dsErr("InvalidMergeMode", { mergeMode }); } ctx.cleanup(modifiedTarget); const cl = modifiedTarget.classList; cl.add(SWAPPING_CLASS); ctx.apply(document.body); setTimeout(() => { initialTarget.classList.remove(SWAPPING_CLASS); cl.remove(SWAPPING_CLASS); }, settleDuration); const revisedHTML = modifiedTarget.outerHTML; if (originalHTML !== revisedHTML) { cl.add(SETTLING_CLASS); setTimeout(() => { cl.remove(SETTLING_CLASS); }, settleDuration); } } } |
Name | MergeSignals |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | |
Path | official/backend/watchers/mergeSignals.ts |
Source | import { DefaultMergeSignalsOnlyIfMissing, EventTypes, } from "../../../../engine/consts"; import { PluginType, WatcherPlugin } from "../../../../engine/types"; import { isBoolString, jsStrToObject } from "../../../../utils/text"; import { datastarSSEEventWatcher } from "../shared"; export const MergeSignals: WatcherPlugin = { type: PluginType.Watcher, name: EventTypes.MergeSignals, onGlobalInit: async (ctx) => { datastarSSEEventWatcher( EventTypes.MergeSignals, ({ signals: raw = "{}", onlyIfMissing: onlyIfMissingRaw = `${DefaultMergeSignalsOnlyIfMissing}`, }) => { const { signals } = ctx; const onlyIfMissing = isBoolString(onlyIfMissingRaw); signals.merge(jsStrToObject(raw), onlyIfMissing); ctx.apply(document.body); }, ); }, }; |
Name | RemoveFragments |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | |
Path | official/backend/watchers/removeFragments.ts |
Source | import { DefaultFragmentsSettleDurationMs, DefaultFragmentsUseViewTransitions, EventTypes, } from "../../../../engine/consts"; import { dsErr } from "../../../../engine/errors"; import { PluginType, WatcherPlugin } from "../../../../engine/types"; import { isBoolString } from "../../../../utils/text"; import { docWithViewTransitionAPI, supportsViewTransitions, } from "../../../../utils/view-transtions"; import { datastarSSEEventWatcher, SWAPPING_CLASS } from "../shared"; export const RemoveFragments: WatcherPlugin = { type: PluginType.Watcher, name: EventTypes.RemoveFragments, onGlobalInit: async () => { datastarSSEEventWatcher( EventTypes.RemoveFragments, ({ selector, settleDuration: settleDurationRaw = `${DefaultFragmentsSettleDurationMs}`, useViewTransition: useViewTransitionRaw = `${DefaultFragmentsUseViewTransitions}`, }) => { if (!!!selector.length) { throw dsErr("NoSelectorProvided"); } const settleDuration = parseInt(settleDurationRaw); const useViewTransition = isBoolString(useViewTransitionRaw); const removeTargets = document.querySelectorAll(selector); const applyToTargets = () => { for (const target of removeTargets) { target.classList.add(SWAPPING_CLASS); } setTimeout(() => { for (const target of removeTargets) { target.remove(); } }, settleDuration); }; if (supportsViewTransitions && useViewTransition) { docWithViewTransitionAPI.startViewTransition(() => applyToTargets() ); } else { applyToTargets(); } }, ); }, }; |
Name | RemoveSignals |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | |
Path | official/backend/watchers/removeSignals.ts |
Source | import { EventTypes } from "../../../../engine/consts"; import { dsErr } from "../../../../engine/errors"; import { PluginType, WatcherPlugin } from "../../../../engine/types"; import { datastarSSEEventWatcher } from "../shared"; export const RemoveSignals: WatcherPlugin = { type: PluginType.Watcher, name: EventTypes.RemoveSignals, onGlobalInit: async (ctx) => { datastarSSEEventWatcher( EventTypes.RemoveSignals, ({ paths: pathsRaw = "" }) => { const paths = pathsRaw.split("\n").map((p) => p.trim()); if (!!!paths?.length) { throw dsErr("NoPathsProvided"); } ctx.signals.remove(...paths); ctx.apply(document.body); }, ); }, }; |
Name | Clipboard |
---|---|
Description | This action copies text to the clipboard using the Clipboard API. |
Authors | Delaney Gillilan |
Path | official/browser/actions/clipboard.ts |
Source | import { dsErr } from "../../../../engine/errors"; import { ActionPlugin, PluginType } from "../../../../engine/types"; export const Clipboard: ActionPlugin = { type: PluginType.Action, name: "clipboard", fn: (_, text) => { if (!navigator.clipboard) { throw dsErr("ClipboardNotAvailable"); } navigator.clipboard.writeText(text); }, }; |
Name | Intersects |
---|---|
Description | An attribute that runs an expression when the element intersects with the viewport. |
Authors | Delaney Gillilan |
Path | official/browser/attributes/intersects.ts |
Source | import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; const ONCE = "once"; const HALF = "half"; const FULL = "full"; export const Intersects: AttributePlugin = { type: PluginType.Attribute, name: "intersects", keyReq: Requirement.Denied, mods: new Set([ONCE, HALF, FULL]), onLoad: ({ el, rawKey, mods, genRX }) => { const options = { threshold: 0 }; if (mods.has(FULL)) options.threshold = 1; else if (mods.has(HALF)) options.threshold = 0.5; const rx = genRX(); const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { rx(); if (mods.has(ONCE)) { observer.disconnect(); delete el.dataset[rawKey]; } } }); }, options); observer.observe(el); return () => observer.disconnect(); }, }; |
Name | Persist |
---|---|
Description | This plugin allows you to persist data to local storage or session storage. Once you add this attribute the data will be persisted to local storage or session storage. |
Authors | Delaney Gillilan |
Path | official/browser/attributes/persist.ts |
Source | import { DATASTAR } from "../../../../engine/consts"; import { AttributePlugin, NestedValues, PluginType, } from "../../../../engine/types"; const SESSION = "session"; export const Persist: AttributePlugin = { type: PluginType.Attribute, name: "persist", mods: new Set([SESSION]), onLoad: ({ key, value, signals, effect, mods }) => { if (key === "") { key = DATASTAR; } const storage = mods.has(SESSION) ? sessionStorage : localStorage; const paths = value.split(/\s+/).filter((p) => p !== ""); const storageToSignals = () => { const data = storage.getItem(key) || "{}"; const nestedValues = JSON.parse(data); signals.merge(nestedValues); }; const signalsToStorage = () => { let nv: NestedValues; if (!!!paths.length) { nv = signals.values(); } else { nv = signals.subset(...paths); } storage.setItem(key, JSON.stringify(nv)); }; storageToSignals(); return effect(() => { signalsToStorage(); }); }, }; |
Name | ReplaceUrl |
---|---|
Description | This plugin allows you to replace the current URL with a new URL. Once you add this attribute the current URL will be replaced with the new URL. |
Authors | Delaney Gillilan |
Path | official/browser/attributes/replaceUrl.ts |
Source | import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; export const ReplaceUrl: AttributePlugin = { type: PluginType.Attribute, name: "replaceUrl", keyReq: Requirement.Denied, valReq: Requirement.Must, onLoad: ({ effect, genRX }) => { const rx = genRX(); return effect(() => { const url = rx<string>(); const baseUrl = window.location.href; const fullUrl = new URL(url, baseUrl).toString(); window.history.replaceState({}, "", fullUrl); }); }, }; |
Name | ScrollIntoView |
---|---|
Description | This attribute scrolls the element into view. |
Authors | Delaney Gillilan |
Path | official/browser/attributes/scrollIntoView.ts |
Source | import { dsErr } from "../../../../engine/errors"; import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; const SMOOTH = "smooth"; const INSTANT = "instant"; const AUTO = "auto"; const HSTART = "hstart"; const HCENTER = "hcenter"; const HEND = "hend"; const HNEAREST = "hnearest"; const VSTART = "vstart"; const VCENTER = "vcenter"; const VEND = "vend"; const VNEAREST = "vnearest"; const FOCUS = "focus"; const CENTER = "center"; const START = "start"; const END = "end"; const NEAREST = "nearest"; export const ScrollIntoView: AttributePlugin = { type: PluginType.Attribute, name: "scrollIntoView", keyReq: Requirement.Denied, valReq: Requirement.Denied, mods: new Set([ SMOOTH, INSTANT, AUTO, HSTART, HCENTER, HEND, HNEAREST, VSTART, VCENTER, VEND, VNEAREST, FOCUS, ]), onLoad: ({ el, mods, rawKey }) => { if (!el.tabIndex) el.setAttribute("tabindex", "0"); const opts: ScrollIntoViewOptions = { behavior: SMOOTH, block: CENTER, inline: CENTER, }; if (mods.has(SMOOTH)) opts.behavior = SMOOTH; if (mods.has(INSTANT)) opts.behavior = INSTANT; if (mods.has(AUTO)) opts.behavior = AUTO; if (mods.has(HSTART)) opts.inline = START; if (mods.has(HCENTER)) opts.inline = CENTER; if (mods.has(HEND)) opts.inline = END; if (mods.has(HNEAREST)) opts.inline = NEAREST; if (mods.has(VSTART)) opts.block = START; if (mods.has(VCENTER)) opts.block = CENTER; if (mods.has(VEND)) opts.block = END; if (mods.has(VNEAREST)) opts.block = NEAREST; if (!(el instanceof HTMLElement || el instanceof SVGElement)) { throw dsErr("NotHtmlSvgElement, el"); } if (!el.tabIndex) { el.setAttribute("tabindex", "0"); } el.scrollIntoView(opts); if (mods.has("focus")) { el.focus(); } delete el.dataset[rawKey]; return () => {}; }, }; |
Name | Show |
---|---|
Description | This attribute shows or hides an element based on the value of the expression. If the expression is true, the element is shown. If the expression is false, the element is hidden. The element is hidden by setting the display property to none. |
Authors | Delaney Gillilan |
Path | official/browser/attributes/show.ts |
Source | import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; const NONE = "none"; const DISPLAY = "display"; export const Show: AttributePlugin = { type: PluginType.Attribute, name: "show", keyReq: Requirement.Denied, valReq: Requirement.Must, onLoad: ( { el: { style: s }, genRX, effect }, ) => { const rx = genRX(); return effect(async () => { const shouldShow = rx<boolean>(); if (shouldShow) { if (s.display === NONE) { s.removeProperty(DISPLAY); } } else { s.setProperty(DISPLAY, NONE); } }); }, }; |
Name | ViewTransition |
---|---|
Description | This attribute plugin sets up view transitions for the current view. This plugin requires the view transition API to be enabled in the browser. If the browser does not support view transitions, an error will be logged to the console. |
Authors | Delaney Gillilan |
Path | official/browser/attributes/viewTransition.ts |
Source | import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; import { supportsViewTransitions } from "../../../../utils/view-transtions"; const VIEW_TRANSITION = "view-transition"; export const ViewTransition: AttributePlugin = { type: PluginType.Attribute, name: VIEW_TRANSITION, keyReq: Requirement.Denied, valReq: Requirement.Must, onGlobalInit() { let hasViewTransitionMeta = false; document.head.childNodes.forEach((node) => { if ( node instanceof HTMLMetaElement && node.name === VIEW_TRANSITION ) { hasViewTransitionMeta = true; } }); if (!hasViewTransitionMeta) { const meta = document.createElement("meta"); meta.name = VIEW_TRANSITION; meta.content = "same-origin"; document.head.appendChild(meta); } }, onLoad: ({ effect, el, genRX }) => { if (!supportsViewTransitions) { console.error("Browser does not support view transitions"); return; } const rx = genRX(); return effect(() => { const name = rx<string>(); if (!name?.length) return; const elVTASTyle = el.style as unknown as CSSStyleDeclaration; elVTASTyle.viewTransitionName = name; }); }, }; |
Name | Attributes |
---|---|
Description | Any attribute can be bound to an expression. The attribute will be updated reactively whenever the expression signal changes. |
Authors | Delaney Gillilan |
Path | official/dom/attributes/attributes.ts |
Source | import { AttributePlugin, NestedValues, PluginType, Requirement, } from "../../../../engine/types"; import { kebabize } from "../../../../utils/text"; export const Attributes: AttributePlugin = { type: PluginType.Attribute, name: "attributes", valReq: Requirement.Must, onLoad: ({ el, genRX, key, effect }) => { const rx = genRX(); if (key === "") { return effect(async () => { const binds = rx<NestedValues>(); Object.entries(binds).forEach(([attr, val]) => { el.setAttribute(attr, val); }); }); } else { key = kebabize(key); return effect(async () => { let value = false; try { value = rx(); } catch (e) {} // let v: string; if (typeof value === "string") { v = value; } else { v = JSON.stringify(value); } if (!v || v === "false" || v === "null" || v === "undefined") { el.removeAttribute(key); } else { el.setAttribute(key, v); } }); } }, }; |
Name | Bind |
---|---|
Description | Any attribute can be bound to an expression. The attribute will be updated reactively whenever the expression signal changes. |
Authors | Delaney Gillilan |
Path | official/dom/attributes/bind.ts |
Source | import { dsErr } from "../../../../engine/errors"; import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; const dataURIRegex = /^data:(?<mime>[^;]+);base64,(?<contents>.*)$/; const updateEvents = ["change", "input", "keydown"]; export const Bind: AttributePlugin = { type: PluginType.Attribute, name: "bind", keyReq: Requirement.Exclusive, valReq: Requirement.Exclusive, onLoad: (ctx) => { const { el, value, key, signals, effect } = ctx; const signalName = !!key ? key : value; let setFromSignal = () => {}; let el2sig = () => {}; // I better be tied to a signal if (typeof signalName !== "string") { throw dsErr("InvalidExpression"); } const tnl = el.tagName.toLowerCase(); let signalDefault: string | boolean | number | File = ""; const isInput = tnl.includes("input"); const type = el.getAttribute("type"); const isCheckbox = tnl.includes("checkbox") || (isInput && type === "checkbox"); if (isCheckbox) { signalDefault = false; } const isNumber = isInput && type === "number"; if (isNumber) { signalDefault = 0; } const isSelect = tnl.includes("select"); const isRadio = tnl.includes("radio") || (isInput && type === "radio"); const isFile = isInput && type === "file"; if (isFile) { // can't set a default value for a file input, yet } if (isRadio) { const name = el.getAttribute("name"); if (!name?.length) { el.setAttribute("name", signalName); } } signals.upsert(signalName, signalDefault); setFromSignal = () => { const hasValue = "value" in el; const v = signals.value(signalName); const vStr = `${v}`; if (isCheckbox || isRadio) { const input = el as HTMLInputElement; if (isCheckbox) { input.checked = !!v || v === "true"; } else if (isRadio) { // evaluate the value as string to handle any type casting // automatically since the attribute has to be a string anyways input.checked = vStr === input.value; } } else if (isFile) { // File input reading from a signal is not supported yet } else if (isSelect) { const select = el as HTMLSelectElement; if (select.multiple) { Array.from(select.options).forEach((opt) => { if (opt?.disabled) return; if (Array.isArray(v) || typeof v === "string") { opt.selected = v.includes(opt.value); } else if (typeof v === "number") { opt.selected = v === Number(opt.value); } else { opt.selected = v as boolean; } }); } else { select.value = vStr; } } else if (hasValue) { el.value = vStr; } else { el.setAttribute("value", vStr); } }; el2sig = async () => { if (isFile) { const files = [...((el as HTMLInputElement)?.files || [])], allContents: string[] = [], allMimes: string[] = [], allNames: string[] = []; await Promise.all( files.map((f) => { return new Promise<void>((resolve) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result !== "string") { throw dsErr( "InvalidFileResultType", { type: typeof reader.result, }, ); } const match = reader.result.match(dataURIRegex); if (!match?.groups) { throw dsErr("InvalidDataUri", { result: reader.result, }); } allContents.push(match.groups.contents); allMimes.push(match.groups.mime); allNames.push(f.name); }; reader.onloadend = () => resolve(void 0); reader.readAsDataURL(f); }); }), ); signals.setValue(signalName, allContents); const mimeName = `${signalName}Mimes`, nameName = `${signalName}Names`; if (mimeName in signals) { signals.upsert(mimeName, allMimes); } if (nameName in signals) { signals.upsert(nameName, allNames); } return; } const current = signals.value(signalName); const input = (el as HTMLInputElement) || (el as HTMLElement); if (typeof current === "number") { const v = Number( input.value || input.getAttribute("value"), ); signals.setValue(signalName, v); } else if (typeof current === "string") { const v = input.value || input.getAttribute("value") || ""; signals.setValue(signalName, v); } else if (typeof current === "boolean") { if (isCheckbox) { const v = input.checked || input.getAttribute("checked") === "true"; signals.setValue(signalName, v); } else { const v = Boolean( input.value || input.getAttribute("value"), ); signals.setValue(signalName, v); } } else if (typeof current === "undefined") { } else if (Array.isArray(current)) { // check if the input is a select element if (isSelect) { const select = el as HTMLSelectElement; const selectedOptions = [...select.selectedOptions]; const selectedValues = selectedOptions .filter((opt) => opt.selected) .map((opt) => opt.value); signals.setValue(signalName, selectedValues); } else { // assume it's a comma-separated string const v = JSON.stringify(input.value.split(",")); signals.setValue(signalName, v); } } else { throw dsErr("UnsupportedSignalType", { current: typeof current, }); } }; updateEvents.forEach((event) => el.addEventListener(event, el2sig)); const elSigClean = effect(() => setFromSignal()); return () => { elSigClean(); updateEvents.forEach((event) => { el.removeEventListener(event, el2sig); }); }; }, }; |
Name | Class |
---|---|
Description | This action adds or removes classes from an element reactively based on the expression provided. The expression should be an object where the keys are the class names and the values are booleans. If the value is true, the class is added. If the value is false, the class is removed. |
Authors | Delaney Gillilan |
Path | official/dom/attributes/class.ts |
Source | import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; import { kebabize } from "../../../../utils/text"; export const Class: AttributePlugin = { type: PluginType.Attribute, name: "class", valReq: Requirement.Must, onLoad: ({ key, el, genRX, effect }) => { const cl = el.classList; const rx = genRX(); return effect(() => { if (key === "") { const classes: Object = rx<Record<string, boolean>>(); for (const [k, v] of Object.entries(classes)) { const classNames = k.split(/\s+/); if (v) { cl.add(...classNames); } else { cl.remove(...classNames); } } } else { const shouldInclude = rx<boolean>(); const cls = kebabize(key); if (shouldInclude) { cl.add(cls); } else { cl.remove(cls); } } }); }, }; |
Name | On |
---|---|
Description | This action adds an event listener to an element. The event listener can be triggered by a variety of events, such as clicks, keypresses, and more. The event listener can also be set to trigger only once, or to be passive or capture. The event listener can also be debounced or throttled. The event listener can also be set to trigger only when the event target is outside the element. |
Authors | Delaney Gillilan |
Path | official/dom/attributes/on.ts |
Source | import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; import { argsHas, argsMs } from "../../../../utils/arguments"; import { onElementRemoved } from "../../../../utils/dom"; import { kebabize } from "../../../../utils/text"; import { debounce, throttle } from "../../../../utils/timing"; let lastSignalsMarshalled = new Map<string, any>(); const EVT = "evt"; export const On: AttributePlugin = { type: PluginType.Attribute, name: "on", keyReq: Requirement.Must, valReq: Requirement.Must, argNames: [EVT], macros: { pre: [ { // We need to escape the evt in case .value is used type: PluginType.Macro, name: "evtEsc", fn: (original) => { return original.replaceAll( /evt.([\w\.]+)value/gm, "EVT_$1_VALUE", ); }, }, ], post: [ { // We need to unescape the evt in case .value is used type: PluginType.Macro, name: "evtUnesc", fn: (original) => { return original.replaceAll( /EVT_([\w\.]+)_VALUE/gm, "evt.$1value", ); }, }, ], }, onLoad: ({ el, key, genRX, mods, signals, effect }) => { const rx = genRX(); let target: Element | Window | Document = el; if (mods.has("window")) target = window; let callback = (evt?: Event) => { if (evt) { if (mods.has("prevent")) evt.preventDefault(); if (mods.has("stop")) evt.stopPropagation(); } rx(evt); }; const debounceArgs = mods.get("debounce"); if (debounceArgs) { const wait = argsMs(debounceArgs); const leading = argsHas(debounceArgs, "leading", false); const trailing = !argsHas(debounceArgs, "noTrail", false); callback = debounce(callback, wait, leading, trailing); } const throttleArgs = mods.get("throttle"); if (throttleArgs) { const wait = argsMs(throttleArgs); const leading = !argsHas(throttleArgs, "noLeading", false); const trailing = argsHas(throttleArgs, "trail", false); callback = throttle(callback, wait, leading, trailing); } const evtListOpts: AddEventListenerOptions = { capture: true, passive: false, once: false, }; if (!mods.has("capture")) evtListOpts.capture = false; if (mods.has("passive")) evtListOpts.passive = true; if (mods.has("once")) evtListOpts.once = true; const eventName = kebabize(key).toLowerCase(); switch (eventName) { case "load": callback(); delete el.dataset.onLoad; return () => {}; case "raf": let rafId: number | undefined; const raf = () => { callback(); rafId = requestAnimationFrame(raf); }; rafId = requestAnimationFrame(raf); return () => { if (rafId) cancelAnimationFrame(rafId); }; case "signals-change": onElementRemoved(el, () => { lastSignalsMarshalled.delete(el.id); }); return effect(() => { const onlyRemoteSignals = mods.has("remote"); const current = signals.JSON(false, onlyRemoteSignals); const last = lastSignalsMarshalled.get(el.id) || ""; if (last !== current) { lastSignalsMarshalled.set(el.id, current); callback(); } }); default: const testOutside = mods.has("outside"); if (testOutside) { target = document; const cb = callback; let called = false; const targetOutsideCallback = (e?: Event) => { const targetHTML = e?.target as HTMLElement; if (!targetHTML) return; const isEl = el.id === targetHTML.id; if (isEl && called) { called = false; } if (!isEl && !called) { cb(e); called = true; } }; callback = targetOutsideCallback; } target.addEventListener(eventName, callback, evtListOpts); return () => { target.removeEventListener(eventName, callback); }; } }, }; |
Name | Ref |
---|---|
Description | This attribute creates a reference to an element that can be used in other expressions. |
Authors | Delaney Gillilan |
Path | official/dom/attributes/ref.ts |
Source | import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; export const Ref: AttributePlugin = { type: PluginType.Attribute, name: "ref", keyReq: Requirement.Exclusive, valReq: Requirement.Exclusive, onLoad: ({ el, key, value, signals }) => { const signalName = !!key ? key : value; signals.upsert(signalName, el); return () => signals.setValue(signalName, null); }, }; |
Name | Text |
---|---|
Description | This attribute sets the text content of an element to the result of the expression. |
Authors | Delaney Gillilan |
Path | official/dom/attributes/text.ts |
Source | import { dsErr } from "../../../../engine/errors"; import { AttributePlugin, PluginType, Requirement, } from "../../../../engine/types"; export const Text: AttributePlugin = { type: PluginType.Attribute, name: "text", keyReq: Requirement.Denied, valReq: Requirement.Must, onLoad: (ctx) => { const { el, genRX, effect } = ctx; const rx = genRX(); if (!(el instanceof HTMLElement)) { dsErr("NotHtmlElement"); } return effect(() => { const res = rx(ctx); el.textContent = `${res}`; }); }, }; |
Name | Fit |
---|---|
Description | This action clamps a value to a new range. The value is first scaled to the new range, then clamped to the new range. This is useful for scaling a value to a new range, then clamping it to that range. |
Authors | Delaney Gillilan |
Path | official/logic/actions/fit.ts |
Source | import { ActionPlugin, PluginType, RuntimeContext, } from "../../../../engine/types"; const { round, max, min } = Math; export const Fit: ActionPlugin = { type: PluginType.Action, name: "fit", fn: ( _: RuntimeContext, v: number, oldMin: number, oldMax: number, newMin: number, newMax: number, shouldClamp = false, shouldRound = false, ) => { let fitted = ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin; if (shouldRound) { fitted = round(fitted); } if (shouldClamp) { fitted = max(newMin, min(newMax, fitted)); } return fitted; }, }; |
Name | SetAll |
---|---|
Authors | Delaney Gillilan |
Path | official/logic/actions/setAll.ts |
Source | import { ActionPlugin, PluginType } from "../../../../engine/types"; export const SetAll: ActionPlugin = { type: PluginType.Action, name: "setAll", fn: ({ signals }, prefix: string, newValue) => { signals.walk((path, signal) => { if (!path.startsWith(prefix)) return; signal.value = newValue; }); }, }; |
Name | ToggleAll |
---|---|
Authors | Delaney Gillilan |
Path | official/logic/actions/toggleAll.ts |
Source | import { ActionPlugin, PluginType } from "../../../../engine/types"; export const ToggleAll: ActionPlugin = { type: PluginType.Action, name: "toggleAll", fn: ({ signals }, prefix: string) => { signals.walk((path, signal) => { if (!path.startsWith(prefix)) return; signal.value = !signal.value; }); }, }; |