Name | DeleteSSE |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | Delaney Gillilan |
Path | official/actions/backend/sseDelete.ts |
Source | import { ActionPlugin } from "../../../../engine"; import { sendSSERequest } from "./sseShared"; export const DeleteSSE: ActionPlugin = { pluginType: "action", name: "delete", method: sendSSERequest("delete"), }; |
Name | GetSSE |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | Delaney Gillilan |
Path | official/actions/backend/sseGet.ts |
Source | import { ActionPlugin } from "../../../../engine"; import { sendSSERequest } from "./sseShared"; export const GetSSE: ActionPlugin = { pluginType: "action", name: "get", method: sendSSERequest("get"), }; |
Name | PatchSSE |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | Delaney Gillilan |
Path | official/actions/backend/ssePatch.ts |
Source | import { ActionPlugin } from "../../../../engine"; import { sendSSERequest } from "./sseShared"; export const PatchSSE: ActionPlugin = { pluginType: "action", name: "patch", method: sendSSERequest("patch"), }; |
Name | PostSSE |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | Delaney Gillilan |
Path | official/actions/backend/ssePost.ts |
Source | import { ActionPlugin } from "../../../../engine"; import { sendSSERequest } from "./sseShared"; export const PostSSE: ActionPlugin = { pluginType: "action", name: "post", method: sendSSERequest("post"), }; |
Name | PutSSE |
---|---|
Description | Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. |
Authors | Delaney Gillilan |
Path | official/actions/backend/ssePut.ts |
Source | import { ActionPlugin } from "../../../../engine"; import { sendSSERequest } from "./sseShared"; export const PutSSE: ActionPlugin = { pluginType: "action", name: "put", method: sendSSERequest("put"), }; |
Name | Clipboard |
---|---|
Description | This action copies text to the clipboard using the Clipboard API. |
Authors | Delaney Gillilan |
Path | official/actions/dom/clipboard.ts |
Source | import { ActionPlugin } from "../../../../engine"; import { ERR_NOT_ALLOWED } from "../../../../engine/errors"; export const Clipboard: ActionPlugin = { pluginType: "action", name: "clipboard", method: (_, text) => { if (!navigator.clipboard) { // Clipboard API not available throw ERR_NOT_ALLOWED; } navigator.clipboard.writeText(text); }, }; |
Name | SetAll |
---|---|
Authors | Delaney Gillilan |
Path | official/actions/logic/setAll.ts |
Source | import { ActionPlugin } from "../../../../engine"; export const SetAll: ActionPlugin = { pluginType: "action", name: "setAll", method: (ctx, regexp, newValue) => { const re = new RegExp(regexp); ctx.walkSignals((name, signal) => re.test(name) && (signal.value = newValue) ); }, }; |
Name | ToggleAll |
---|---|
Authors | Delaney Gillilan |
Path | official/actions/logic/toggleAll.ts |
Source | import { ActionPlugin } from "../../../../engine"; export const ToggleAll: ActionPlugin = { pluginType: "action", name: "toggleAll", method: (ctx, regexp) => { const re = new RegExp(regexp); ctx.walkSignals((name, signal) => re.test(name) && (signal.value = !signal.value) ); }, }; |
Name | ClampFit |
---|---|
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/actions/math/clampFit.ts |
Source | import { ActionPlugin, AttributeContext } from "../../../../engine"; export const ClampFit: ActionPlugin = { pluginType: "action", name: "clampFit", method: ( _: AttributeContext, v: number, oldMin: number, oldMax: number, newMin: number, newMax: number, ) => { return Math.max( newMin, Math.min( newMax, ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin, ), ); }, }; |
Name | ClampFitInt |
---|---|
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. The result is then rounded to the nearest integer. |
Authors | Delaney Gillilan |
Path | official/actions/math/clampFitInt.ts |
Source | import { ActionPlugin, AttributeContext } from "../../../../engine"; export const ClampFitInt: ActionPlugin = { pluginType: "action", name: "clampFitInt", method: ( _: AttributeContext, v: number, oldMin: number, oldMax: number, newMin: number, newMax: number, ) => { return Math.round( Math.max( newMin, Math.min( newMax, ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin, ), ), ); }, }; |
Name | Fit |
---|---|
Description | This action linearly fits a value to a new range. The value is first scaled to the new range. Note it is not clamped to the new range. |
Authors | Delaney Gillilan |
Path | official/actions/math/fit.ts |
Source | import { ActionPlugin, AttributeContext } from "../../../../engine"; export const Fit: ActionPlugin = { pluginType: "action", name: "fit", method: ( _: AttributeContext, v: number, oldMin: number, oldMax: number, newMin: number, newMax: number, ) => { return ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin; }, }; |
Name | FitInt |
---|---|
Description | This action linearly fits a value to a new range. The value is first scaled to the new range. Note it is not clamped to the new range. |
Authors | Delaney Gillilan |
Path | official/actions/math/fitInt.ts |
Source | import { ActionPlugin, AttributeContext } from "../../../../engine"; export const FitInt: ActionPlugin = { pluginType: "action", name: "fitInt", method: ( _: AttributeContext, v: number, oldMin: number, oldMax: number, newMin: number, newMax: number, ) => { return Math.round( ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin, ); }, }; |
Name | Indicator |
---|---|
Description | must be a valid signal name |
Authors | Delaney Gillilan |
Path | official/attributes/backend/indicator.ts |
Source | import { AttributePlugin } from "../../../../engine"; import { DATASTAR } from "../../../../engine/consts"; import { ERR_NOT_FOUND } from "../../../../engine/errors"; import { isDatastarGeneratedID } from "../../../../utils/regex"; import { DATASTAR_SSE_EVENT, DatastarSSEEvent, FINISHED, STARTED, } from "../../watchers/backend/sseShared"; export const INDICATOR_CLASS = `${DATASTAR}-indicator`; export const INDICATOR_LOADING_CLASS = `${INDICATOR_CLASS}-loading`; export const Indicator: AttributePlugin = { pluginType: "attribute", name: "indicator", mustHaveEmptyKey: true, onLoad: (ctx) => { const { expression, upsertSignal, el } = ctx; if (isDatastarGeneratedID(el)) { // Indicator cannot be used on an element without an ID // otherwise it will auto generate and most like will be incorrect // if you get to the point match sure this element has a unique ID. throw ERR_NOT_FOUND; } const signalName = expression; const signal = upsertSignal(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 | 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/attributes/dom/bind.ts |
Source | import { AttributePlugin } from "../../../../engine"; import { kebabize } from "../../../../utils/text"; export const Bind: AttributePlugin = { pluginType: "attribute", name: "bind", mustNotEmptyKey: true, mustNotEmptyExpression: true, onLoad: (ctx) => { return ctx.reactivity.effect(async () => { const key = kebabize(ctx.key); const value = ctx.expressionFn(ctx); let v: string; if (typeof value === "string") { v = value; } else { v = JSON.stringify(value); } if (!v || v === "false" || v === "null" || v === "undefined") { ctx.el.removeAttribute(key); } else { ctx.el.setAttribute(key, v); } }); }, }; |
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/attributes/dom/class.ts |
Source | import { AttributePlugin } from "../../../../engine"; export const Class: AttributePlugin = { pluginType: "attribute", name: "class", mustHaveEmptyKey: true, mustNotEmptyExpression: true, onLoad: (ctx) => { return ctx.reactivity.effect(() => { const classes: Object = ctx.expressionFn(ctx); for (const [k, v] of Object.entries(classes)) { const classNames = k.split(" "); if (v) { ctx.el.classList.add(...classNames); } else { ctx.el.classList.remove(...classNames); } } }); }, }; |
Name | Model |
---|---|
Description | This attribute plugin enables two-way data binding for input elements. |
Authors | Delaney Gillilan |
Path | official/attributes/dom/model.ts |
Source | import { AttributePlugin } from "../../../../engine"; import { ERR_BAD_ARGS, ERR_METHOD_NOT_ALLOWED, } from "../../../../engine/errors"; const dataURIRegex = /^data:(?<mime>[^;]+);base64,(?<contents>.*)$/; const updateModelEvents = ["change", "input", "keydown"]; export const Model: AttributePlugin = { pluginType: "attribute", name: "model", mustHaveEmptyKey: true, // bypassExpressionFunctionCreation: () => true, onLoad: (ctx) => { const { el, expression, upsertSignal } = ctx; const signalName = expression; if (typeof signalName !== "string") { // Signal name must be a string throw ERR_BAD_ARGS; } const tnl = el.tagName.toLowerCase(); let signalDefault: string | boolean | File = ""; const isInput = tnl.includes("input"); const type = el.getAttribute("type"); const isCheckbox = tnl.includes("checkbox") || (isInput && type === "checkbox"); if (isCheckbox) { signalDefault = false; } 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); } } const signal = upsertSignal(signalName, signalDefault); const setInputFromSignal = () => { const hasValue = "value" in el; const v = signal.value; const vStr = `${v}`; if (isCheckbox || isRadio) { const input = el as HTMLInputElement; if (isCheckbox) { input.checked = v; } 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; opt.selected = v.includes(opt.value); }); } else { select.value = vStr; } } else if (hasValue) { el.value = vStr; } else { el.setAttribute("value", vStr); } }; const cleanupSetInputFromSignal = ctx.reactivity.effect( setInputFromSignal, ); const setSignalFromInput = 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") { // console.error(`Invalid result type: ${typeof reader.result}`); throw ERR_BAD_ARGS; } const match = reader.result.match(dataURIRegex); if (!match?.groups) { // console.error(`Invalid data URI: ${reader.result}`); throw ERR_BAD_ARGS; } allContents.push(match.groups.contents); allMimes.push(match.groups.mime); allNames.push(f.name); }; reader.onloadend = () => resolve(void 0); reader.readAsDataURL(f); }); }), ); signal.value = allContents; const s = ctx.store(); const mimeName = `${signalName}Mimes`, nameName = `${signalName}Names`; if (mimeName in s) { s[`${mimeName}`].value = allMimes; } if (nameName in s) { s[`${nameName}`].value = allNames; } return; } const current = signal.value; const input = (el as HTMLInputElement) || (el as HTMLElement); if (typeof current === "number") { signal.value = Number( input.value || input.getAttribute("value"), ); } else if (typeof current === "string") { signal.value = input.value || input.getAttribute("value") || ""; } else if (typeof current === "boolean") { if (isCheckbox) { signal.value = input.checked || input.getAttribute("checked") === "true"; } else { signal.value = Boolean( input.value || input.getAttribute("value"), ); } } else if (typeof current === "undefined") { } else if (typeof current === "bigint") { signal.value = BigInt( input.value || input.getAttribute("value") || "0", ); } 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.map((opt) => opt.value ); signal.value = selectedValues; } else { signal.value = JSON.parse(input.value).split(","); } console.log(input.value); } else { // console.log(`Unsupported type ${typeof current}`); throw ERR_METHOD_NOT_ALLOWED; } }; const parts = el.tagName.split("-"); const isCustomElement = parts.length > 1; if (isCustomElement) { const customElementPrefix = parts[0].toLowerCase(); updateModelEvents.forEach((eventType) => { updateModelEvents.push(`${customElementPrefix}-${eventType}`); }); } updateModelEvents.forEach((eventType) => el.addEventListener(eventType, setSignalFromInput) ); return () => { cleanupSetInputFromSignal(); updateModelEvents.forEach((event) => el.removeEventListener(event, setSignalFromInput) ); }; }, }; |
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/attributes/dom/on.ts |
Source | import { AttributePlugin } from "../../../../engine"; import { ERR_BAD_ARGS } from "../../../../engine/errors"; import { argsHas, argsToMs } from "../../../../utils/arguments"; import { remoteSignals } from "../../../../utils/signals"; import { kebabize } from "../../../../utils/text"; import { debounce, throttle } from "../../../../utils/timing"; const knownOnModifiers = new Set([ "window", "once", "passive", "capture", "debounce", "throttle", "remote", "outside", ]); let lastStoreMarshalled = ""; export const On: AttributePlugin = { pluginType: "attribute", name: "on", mustNotEmptyKey: true, mustNotEmptyExpression: true, argumentNames: ["evt"], onLoad: (ctx) => { const { el, key, expressionFn } = ctx; let target: Element | Window | Document = ctx.el; if (ctx.modifiers.get("window")) { target = window; } let callback = (evt?: Event) => { expressionFn(ctx, evt); }; const debounceArgs = ctx.modifiers.get("debounce"); if (debounceArgs) { const wait = argsToMs(debounceArgs); const leading = argsHas(debounceArgs, "leading", false); const trailing = argsHas(debounceArgs, "noTrail", true); callback = debounce(callback, wait, leading, trailing); } const throttleArgs = ctx.modifiers.get("throttle"); if (throttleArgs) { const wait = argsToMs(throttleArgs); const leading = argsHas(throttleArgs, "noLead", true); const trailing = argsHas(throttleArgs, "noTrail", false); callback = throttle(callback, wait, leading, trailing); } const evtListOpts: AddEventListenerOptions = { capture: true, passive: false, once: false, }; if (!ctx.modifiers.has("capture")) evtListOpts.capture = false; if (ctx.modifiers.has("passive")) evtListOpts.passive = true; if (ctx.modifiers.has("once")) evtListOpts.once = true; const unknownModifierKeys = [...ctx.modifiers.keys()].filter((key) => !knownOnModifiers.has(key) ); unknownModifierKeys.forEach((attrName) => { const eventValues = ctx.modifiers.get(attrName) || []; const cb = callback; const revisedCallback = () => { const evt = event as any; const attr = evt[attrName]; let valid: boolean; if (typeof attr === "function") { valid = attr(...eventValues); } else if (typeof attr === "boolean") { valid = attr; } else if (typeof attr === "string") { const lowerAttr = attr.toLowerCase().trim(); const expr = eventValues.join("").toLowerCase().trim(); valid = lowerAttr === expr; } else { // console.error(`Invalid value for ${attrName} modifier on ${key} on ${el}`); throw ERR_BAD_ARGS; } if (valid) { cb(evt); } }; callback = revisedCallback; }); const eventName = kebabize(key).toLowerCase(); switch (eventName) { case "load": callback(); delete ctx.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 "store-change": return ctx.reactivity.effect(() => { const store = ctx.store(); let storeValue = store.value; if (ctx.modifiers.has("remote")) { storeValue = remoteSignals(storeValue); } const current = JSON.stringify(storeValue); if (lastStoreMarshalled !== current) { lastStoreMarshalled = current; callback(); } }); default: const testOutside = ctx.modifiers.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 () => { // console.log(`Removing event listener for ${eventName} on ${el}`) 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/attributes/dom/ref.ts |
Source | import { AttributePlugin } from "../../../../engine"; export const Ref: AttributePlugin = { pluginType: "attribute", name: "ref", mustHaveEmptyKey: true, mustNotEmptyExpression: true, bypassExpressionFunctionCreation: () => true, onLoad: (ctx) => { const signalName = ctx.expression; ctx.upsertSignal(signalName, ctx.el); return () => { ctx.removeSignals(signalName); }; }, }; |
Name | Text |
---|---|
Description | This attribute sets the text content of an element to the result of the expression. |
Authors | Delaney Gillilan |
Path | official/attributes/dom/text.ts |
Source | import { AttributePlugin } from "../../../../engine"; import { ERR_BAD_ARGS } from "../../../../engine/errors"; export const Text: AttributePlugin = { pluginType: "attribute", name: "text", mustHaveEmptyKey: true, onLoad: (ctx) => { const { el, expressionFn } = ctx; if (!(el instanceof HTMLElement)) { // Element is not HTMLElement throw ERR_BAD_ARGS; } return ctx.reactivity.effect(() => { const res = expressionFn(ctx); el.textContent = `${res}`; }); }, }; |
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/attributes/storage/persist.ts |
Source | import { AttributePlugin } from "../../../../engine"; import { DATASTAR, DATASTAR_EVENT } from "../../../../engine/consts"; import { remoteSignals } from "../../../../utils/signals"; import { DatastarSSEEvent } from "../../watchers/backend/sseShared"; export const Persist: AttributePlugin = { pluginType: "attribute", name: "persist", allowedModifiers: new Set(["local", "session", "remote"]), onLoad: (ctx) => { const key = ctx.key || DATASTAR; const expression = ctx.expression; const keys = new Set<string>(); if (expression.trim() !== "") { const value = ctx.expressionFn(ctx); const parts = value.split(" "); for (const part of parts) { keys.add(part); } } let lastMarshalled = ""; const storageType = ctx.modifiers.has("session") ? "session" : "local"; const useRemote = ctx.modifiers.has("remote"); const storeUpdateHandler = ((_: CustomEvent<DatastarSSEEvent>) => { let store = ctx.store(); if (useRemote) { store = remoteSignals(store); } if (keys.size > 0) { const newStore: Record<string, any> = {}; for (const key of keys) { const parts = key.split("."); let newSubstore = newStore; let subStore = store; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!newSubstore[part]) { newSubstore[part] = {}; } newSubstore = newSubstore[part]; subStore = subStore[part]; } const lastPart = parts[parts.length - 1]; newSubstore[lastPart] = subStore[lastPart]; } store = newStore; } const marshalledStore = JSON.stringify(store); if (marshalledStore === lastMarshalled) { return; } if (storageType === "session") { window.sessionStorage.setItem(key, marshalledStore); } else { window.localStorage.setItem(key, marshalledStore); } lastMarshalled = marshalledStore; }) as EventListener; window.addEventListener(DATASTAR_EVENT, storeUpdateHandler); let marshalledStore: string | null; if (storageType === "session") { marshalledStore = window.sessionStorage.getItem(key); } else { marshalledStore = window.localStorage.getItem(key); } if (!!marshalledStore) { const store = JSON.parse(marshalledStore); for (const key in store) { ctx.upsertSignal(key, store[key]); } } return () => { window.removeEventListener(DATASTAR_EVENT, storeUpdateHandler); }; }, }; |
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/attributes/url/replaceUrl.ts |
Source | import { AttributePlugin } from "../../../../engine"; export const ReplaceUrl: AttributePlugin = { pluginType: "attribute", name: "replaceUrl", mustHaveEmptyKey: true, mustNotEmptyExpression: true, onLoad: (ctx) => { return ctx.reactivity.effect(() => { const value = ctx.expressionFn(ctx); const baseUrl = window.location.href; const url = new URL(value, baseUrl).toString(); window.history.replaceState({}, "", url); }); }, }; |
Name | Intersection |
---|---|
Description | An attribute that runs an expression when the element intersects with the viewport. |
Authors | Delaney Gillilan |
Path | official/attributes/visibility/intersects.ts |
Source | import { AttributePlugin } from "../../../../engine"; const ONCE = "once"; const HALF = "half"; const FULL = "full"; export const Intersection: AttributePlugin = { pluginType: "attribute", name: "intersects", allowedModifiers: new Set([ONCE, HALF, FULL]), mustHaveEmptyKey: true, onLoad: (ctx) => { const { modifiers } = ctx; const options = { threshold: 0 }; if (modifiers.has(FULL)) options.threshold = 1; else if (modifiers.has(HALF)) options.threshold = 0.5; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { ctx.expressionFn(ctx); if (modifiers.has(ONCE)) { observer.disconnect(); delete ctx.el.dataset[ctx.rawKey]; } } }); }, options); observer.observe(ctx.el); return () => observer.disconnect(); }, }; |
Name | ScrollIntoView |
---|---|
Description | This attribute scrolls the element into view. |
Authors | Delaney Gillilan |
Path | official/attributes/visibility/scrollIntoView.ts |
Source | import { AttributeContext, AttributePlugin } from "../../../../engine"; import { scrollIntoView } from "../../../../utils/dom"; 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 = { pluginType: "attribute", name: "scrollIntoView", mustHaveEmptyKey: true, mustHaveEmptyExpression: true, allowedModifiers: new Set([ SMOOTH, INSTANT, AUTO, HSTART, HCENTER, HEND, HNEAREST, VSTART, VCENTER, VEND, VNEAREST, FOCUS, ]), onLoad: ({ el, modifiers, rawKey }: AttributeContext) => { if (!el.tabIndex) el.setAttribute("tabindex", "0"); const opts: ScrollIntoViewOptions = { behavior: SMOOTH, block: CENTER, inline: CENTER, }; if (modifiers.has(SMOOTH)) opts.behavior = SMOOTH; if (modifiers.has(INSTANT)) opts.behavior = INSTANT; if (modifiers.has(AUTO)) opts.behavior = AUTO; if (modifiers.has(HSTART)) opts.inline = START; if (modifiers.has(HCENTER)) opts.inline = CENTER; if (modifiers.has(HEND)) opts.inline = END; if (modifiers.has(HNEAREST)) opts.inline = NEAREST; if (modifiers.has(VSTART)) opts.block = START; if (modifiers.has(VCENTER)) opts.block = CENTER; if (modifiers.has(VEND)) opts.block = END; if (modifiers.has(VNEAREST)) opts.block = NEAREST; scrollIntoView(el, opts, modifiers.has("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/attributes/visibility/show.ts |
Source | import { AttributePlugin } from "../../../../engine"; export const Show: AttributePlugin = { pluginType: "attribute", name: "show", mustHaveEmptyKey: true, mustNotEmptyExpression: true, onLoad: (ctx) => { return ctx.reactivity.effect(async () => { const shouldShow: boolean = ctx.expressionFn(ctx); if (shouldShow) { if (ctx.el.style.display === "none") { ctx.el.style.removeProperty("display"); } } else { ctx.el.style.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/attributes/visibility/viewTransition.ts |
Source | import { AttributePlugin } from "../../../../engine"; import { supportsViewTransitions } from "../../../../utils/view-transitions"; const VIEW_TRANSITION = "view-transition"; export const ViewTransition: AttributePlugin = { pluginType: "attribute", name: VIEW_TRANSITION, 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: (ctx) => { if (!supportsViewTransitions) { console.error("Browser does not support view transitions"); return; } return ctx.reactivity.effect(() => { const { el, expressionFn } = ctx; let name = expressionFn(ctx); if (!name) return; const elVTASTyle = el.style as unknown as CSSStyleDeclaration; elVTASTyle.viewTransitionName = name; }); }, }; |
Name | ExecuteScript |
---|---|
Description | Execute JavaScript from a Server-Sent Event |
Authors | Delaney Gillilan |
Path | official/watchers/backend/sseExecuteScript.ts |
Source | import { WatcherPlugin } from "../../../../engine/types"; import { DefaultExecuteScriptAttributes, DefaultExecuteScriptAutoRemove, EventTypes, } from "../../../../engine/consts"; import { ERR_BAD_ARGS } from "../../../../engine/errors"; import { isBoolString } from "../../../../utils/text"; import { datastarSSEEventWatcher } from "./sseShared"; export const ExecuteScript: WatcherPlugin = { pluginType: "watcher", name: EventTypes.ExecuteScript, onGlobalInit: async () => { datastarSSEEventWatcher( EventTypes.ExecuteScript, ( { autoRemove: autoRemoveRaw = `${DefaultExecuteScriptAutoRemove}`, attributes: attributesRaw = DefaultExecuteScriptAttributes, script, }, ) => { const autoRemove = isBoolString(autoRemoveRaw); if (!script?.length) { // No script provided throw ERR_BAD_ARGS; } const scriptEl = document.createElement("script"); attributesRaw.split("\n").forEach((attr) => { const pivot = attr.indexOf(" "); const key = attr.slice(0, pivot).trim(); const value = attr.slice(pivot).trim(); scriptEl.setAttribute(key, value); }); 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 | Delaney Gillilan |
Path | official/watchers/backend/sseMergeFragment.ts |
Source | import { InitContext, WatcherPlugin } from "../../../../engine"; import { DefaultFragmentMergeMode, DefaultFragmentsUseViewTransitions, DefaultSettleDurationMs, EventTypes, FragmentMergeModes, } from "../../../../engine/consts"; import { ERR_BAD_ARGS } from "../../../../engine/errors"; import { isBoolString } from "../../../../utils/text"; import { docWithViewTransitionAPI, supportsViewTransitions, } from "../../../../utils/view-transitions"; import { idiomorph } from "../../../../vendored/idiomorph"; import { datastarSSEEventWatcher, SETTLING_CLASS, SWAPPING_CLASS, } from "./sseShared"; export const MergeFragments: WatcherPlugin = { 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 = `${DefaultSettleDurationMs}`, 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)) { // No fragments found throw ERR_BAD_ARGS; } const selectorOrID = selector || `#${fragment.getAttribute("id")}`; const targets = document.querySelectorAll(selectorOrID) || []; const allTargets = [...targets]; if (!allTargets.length) { // No targets found throw ERR_BAD_ARGS; } if (supportsViewTransitions && useViewTransition) { docWithViewTransitionAPI.startViewTransition(() => applyToTargets( ctx, mergeMode, settleDuration, fragment, allTargets, ) ); } else { applyToTargets( ctx, mergeMode, settleDuration, fragment, allTargets, ); } }); }); }, }; 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) { // No morph result throw ERR_BAD_ARGS; } 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: // Unknown merge type throw ERR_BAD_ARGS; } ctx.cleanup(modifiedTarget); modifiedTarget.classList.add(SWAPPING_CLASS); ctx.applyPlugins(document.body); setTimeout(() => { initialTarget.classList.remove(SWAPPING_CLASS); modifiedTarget.classList.remove(SWAPPING_CLASS); }, settleDuration); const revisedHTML = modifiedTarget.outerHTML; if (originalHTML !== revisedHTML) { modifiedTarget.classList.add(SETTLING_CLASS); setTimeout(() => { modifiedTarget.classList.remove( SETTLING_CLASS, ); }, settleDuration); } } } |
Name | MergeSignals |
---|---|
Description | Merge store data from a server using the Datastar SDK interface |
Authors | Delaney Gillilan |
Path | official/watchers/backend/sseMergeSignals.ts |
Source | import { InitExpressionFunction, WatcherPlugin } from "../../../../engine"; import { DefaultMergeSignalsOnlyIfMissing, EventTypes, } from "../../../../engine/consts"; import { storeFromPossibleContents } from "../../../../utils/signals"; import { isBoolString } from "../../../../utils/text"; import { datastarSSEEventWatcher } from "./sseShared"; export const MergeSignals: WatcherPlugin = { pluginType: "watcher", name: EventTypes.MergeSignals, onGlobalInit: async (ctx) => { datastarSSEEventWatcher(EventTypes.MergeSignals, ({ signals = "{}", onlyIfMissing: onlyIfMissingRaw = `${DefaultMergeSignalsOnlyIfMissing}`, }) => { const onlyIfMissing = isBoolString(onlyIfMissingRaw); const fnContents = ` return Object.assign({...ctx.store()}, ${signals})`; try { const fn = new Function( "ctx", fnContents, ) as InitExpressionFunction; const possibleMergeSignals = fn(ctx); const actualMergeSignals = storeFromPossibleContents( ctx.store(), possibleMergeSignals, onlyIfMissing, ); ctx.mergeSignals(actualMergeSignals); ctx.applyPlugins(document.body); } catch (e) { console.log(fnContents); console.error(e); debugger; } }); }, }; |
Name | RemoveFragments |
---|---|
Description | Merge store data from a server using the Datastar SDK interface |
Authors | Delaney Gillilan |
Path | official/watchers/backend/sseRemoveFragments.ts |
Source | import { WatcherPlugin } from "../../../../engine"; import { DefaultFragmentsUseViewTransitions, DefaultSettleDurationMs, EventTypes, } from "../../../../engine/consts"; import { ERR_BAD_ARGS } from "../../../../engine/errors"; import { isBoolString } from "../../../../utils/text"; import { docWithViewTransitionAPI, supportsViewTransitions, } from "../../../../utils/view-transitions"; import { datastarSSEEventWatcher, SWAPPING_CLASS } from "./sseShared"; export const RemoveFragments: WatcherPlugin = { pluginType: "watcher", name: EventTypes.RemoveFragments, onGlobalInit: async () => { datastarSSEEventWatcher(EventTypes.RemoveFragments, ({ selector, settleDuration: settleDurationRaw = `${DefaultSettleDurationMs}`, useViewTransition: useViewTransitionRaw = `${DefaultFragmentsUseViewTransitions}`, }) => { if (!!!selector.length) { // No selector provided for remove-fragments throw ERR_BAD_ARGS; } 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 | Merge store data from a server using the Datastar SDK interface |
Authors | Delaney Gillilan |
Path | official/watchers/backend/sseRemoveSignals.ts |
Source | import { EventTypes } from "../../../../engine/consts"; import { ERR_BAD_ARGS } from "../../../../engine/errors"; import { WatcherPlugin } from "../../../../engine/types"; import { datastarSSEEventWatcher } from "./sseShared"; export const RemoveSignals: WatcherPlugin = { pluginType: "watcher", name: EventTypes.RemoveSignals, onGlobalInit: async (ctx) => { datastarSSEEventWatcher( EventTypes.RemoveSignals, ({ paths: pathsRaw = "" }) => { // replace all whitespace with a single space pathsRaw = pathsRaw.replaceAll(/\s+/g, " "); if (!!!pathsRaw?.length) { // No paths provided for remove-signals throw ERR_BAD_ARGS; } const paths = pathsRaw.split(" "); ctx.removeSignals(...paths); }, ); }, }; |