Name | DELETE |
---|---|
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/delete.ts |
Source | import { type ActionPlugin, PluginType, type RuntimeContext, } from '../../../../engine/types' import { type SSEArgs, sse } from './sse' export const DELETE: ActionPlugin = { type: PluginType.Action, name: 'delete', fn: async (ctx: RuntimeContext, url: string, args: SSEArgs) => { return sse(ctx, 'DELETE', url, { ...args }) }, } |
Name | GET |
---|---|
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/get.ts |
Source | import { type ActionPlugin, PluginType, type RuntimeContext, } from '../../../../engine/types' import { type SSEArgs, sse } from './sse' export const GET: ActionPlugin = { type: PluginType.Action, name: 'get', fn: async (ctx: RuntimeContext, url: string, args: SSEArgs) => { return sse(ctx, 'GET', url, { ...args }) }, } |
Name | PATCH |
---|---|
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/patch.ts |
Source | import { type ActionPlugin, PluginType, type RuntimeContext, } from '../../../../engine/types' import { type SSEArgs, sse } from './sse' export const PATCH: ActionPlugin = { type: PluginType.Action, name: 'patch', fn: async (ctx: RuntimeContext, url: string, args: SSEArgs) => { return sse(ctx, 'PATCH', url, { ...args }) }, } |
Name | POST |
---|---|
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/post.ts |
Source | import { type ActionPlugin, PluginType, type RuntimeContext, } from '../../../../engine/types' import { type SSEArgs, sse } from './sse' export const POST: ActionPlugin = { type: PluginType.Action, name: 'post', fn: async (ctx: RuntimeContext, url: string, args: SSEArgs) => { return sse(ctx, 'POST', url, { ...args }) }, } |
Name | PUT |
---|---|
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/put.ts |
Source | import { type ActionPlugin, PluginType, type RuntimeContext, } from '../../../../engine/types' import { type SSEArgs, sse } from './sse' export const PUT: ActionPlugin = { type: PluginType.Action, name: 'put', fn: async (ctx: RuntimeContext, url: string, args: SSEArgs) => { return sse(ctx, 'PUT', url, { ...args }) }, } |
Name | Indicator |
---|---|
Description | must be a valid signal name |
Authors | |
Path | official/backend/attributes/indicator.ts |
Source | import { type AttributePlugin, PluginType, Requirement, } from '../../../../engine/types' import { trimDollarSignPrefix } from '../../../../utils/text' import { DATASTAR_SSE_EVENT, type DatastarSSEEvent, FINISHED, STARTED, } from '../shared' export const Indicator: AttributePlugin = { type: PluginType.Attribute, name: 'indicator', keyReq: Requirement.Exclusive, valReq: Requirement.Exclusive, onLoad: ({ value, signals, el, key }) => { const signalName = key ? key : trimDollarSignPrefix(value) const signal = signals.upsertIfMissing(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 { initErr } from '../../../../engine/errors' import { PluginType, type 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 (ctx) => { datastarSSEEventWatcher( EventTypes.ExecuteScript, ({ autoRemove: autoRemoveRaw = `${DefaultExecuteScriptAutoRemove}`, attributes: attributesRaw = DefaultExecuteScriptAttributes, script, }) => { const autoRemove = isBoolString(autoRemoveRaw) if (!script?.length) { throw initErr('NoScriptProvided', ctx) } const scriptEl = document.createElement('script') for (const attr of attributesRaw.split('\n')) { 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 { initErr } from '../../../../engine/errors' import { type InitContext, PluginType, type WatcherPlugin, } from '../../../../engine/types' import { isBoolString } from '../../../../utils/text' import { docWithViewTransitionAPI, supportsViewTransitions, } from '../../../../utils/view-transtions' import { idiomorph } from '../../../../vendored/idiomorph' import { SETTLING_CLASS, SWAPPING_CLASS, datastarSSEEventWatcher, } 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 = Number.parseInt(settleDurationRaw) const useViewTransition = isBoolString(useViewTransitionRaw) fragmentContainer.innerHTML = fragmentsRaw.trim() const fragments = [...fragmentContainer.content.children] for (const fragment of fragments) { if (!(fragment instanceof Element)) { throw initErr('NoFragmentsFound', ctx) } const selectorOrID = selector || `#${fragment.getAttribute('id')}` const targets = [...(document.querySelectorAll(selectorOrID) || [])] if (!targets.length) { throw initErr('NoTargetsFound', ctx, { 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 initErr('MorphFailed', ctx) } 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 for (const attrName of fragment.getAttributeNames()) { const value = fragment.getAttribute(attrName)! modifiedTarget.setAttribute(attrName, value) } break default: throw initErr('InvalidMergeMode', ctx, { 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, type 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 { initErr } from '../../../../engine/errors' import { PluginType, type WatcherPlugin } from '../../../../engine/types' import { isBoolString } from '../../../../utils/text' import { docWithViewTransitionAPI, supportsViewTransitions, } from '../../../../utils/view-transtions' import { SWAPPING_CLASS, datastarSSEEventWatcher } from '../shared' export const RemoveFragments: WatcherPlugin = { type: PluginType.Watcher, name: EventTypes.RemoveFragments, onGlobalInit: async (ctx) => { datastarSSEEventWatcher( EventTypes.RemoveFragments, ({ selector, settleDuration: settleDurationRaw = `${DefaultFragmentsSettleDurationMs}`, useViewTransition: useViewTransitionRaw = `${DefaultFragmentsUseViewTransitions}`, }) => { if (!selector.length) { throw initErr('NoSelectorProvided', ctx) } const settleDuration = Number.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 { initErr } from '../../../../engine/errors' import { PluginType, type 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 initErr('NoPathsProvided', ctx) } 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 { runtimeErr } from '../../../../engine/errors' import { type ActionPlugin, PluginType } from '../../../../engine/types' export const Clipboard: ActionPlugin = { type: PluginType.Action, name: 'clipboard', fn: (ctx, text) => { if (!navigator.clipboard) { throw runtimeErr('ClipboardNotAvailable', ctx) } navigator.clipboard.writeText(text) }, } |
Name | CustomValidity |
---|---|
Description | This plugin allows you to add custom validity to an element using an expression. The expression should evaluate to a string that will be set as the custom validity message. This can be used to provide custom error messages for form validation. |
Authors | Ben Croker |
Path | official/browser/attributes/customValidity.ts |
Source | import { runtimeErr } from '../../../../engine/errors' import { type AttributePlugin, PluginType, Requirement, } from '../../../../engine/types' export const CustomValidity: AttributePlugin = { type: PluginType.Attribute, name: 'customValidity', keyReq: Requirement.Denied, valReq: Requirement.Must, onLoad: (ctx) => { const { el, genRX, effect } = ctx if (!(el instanceof HTMLInputElement)) { throw runtimeErr('CustomValidityInvalidElement', ctx) } const rx = genRX() return effect(() => { const result = rx<string>() if (typeof result !== 'string') { throw runtimeErr('CustomValidityInvalidExpression', ctx, { result }) } el.setCustomValidity(result) }) }, } |
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 { type 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) => { for (const entry of entries) { 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 { type AttributePlugin, type NestedValues, PluginType, } from '../../../../engine/types' import { trimDollarSignPrefix } from '../../../../utils/text' 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 let paths = value.split(/\s+/).filter((p) => p !== '') paths = paths.map((p) => trimDollarSignPrefix(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 { type 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 { runtimeErr } from '../../../../engine/errors' import { type 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: (ctx) => { const { el, mods, rawKey } = ctx 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 runtimeErr('ScrollIntoViewInvalidElement', ctx) } 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 { type 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 { type 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: 'viewTransition', keyReq: Requirement.Denied, valReq: Requirement.Must, onGlobalInit() { let hasViewTransitionMeta = false for (const node of document.head.childNodes) { 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 | Attr |
---|---|
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/attr.ts |
Source | import { type AttributePlugin, type NestedValues, PluginType, Requirement, } from '../../../../engine/types' import { kebabize } from '../../../../utils/text' export const Attr: AttributePlugin = { type: PluginType.Attribute, name: 'attr', valReq: Requirement.Must, onLoad: ({ el, genRX, key, effect }) => { const rx = genRX() if (key === '') { return effect(async () => { const binds = rx<NestedValues>() for (const [attr, val] of Object.entries(binds)) { el.setAttribute(attr, val) } }) } 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 { runtimeErr } from '../../../../engine/errors' import { type AttributePlugin, PluginType, Requirement, } from '../../../../engine/types' import { trimDollarSignPrefix } from '../../../../utils/text' 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 : trimDollarSignPrefix(value) let setFromSignal = () => {} let el2sig = () => {} 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.upsertIfMissing(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) { for (const opt of select.options) { 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 || [])] const allContents: string[] = [] const allMimes: string[] = [] const 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 runtimeErr('InvalidFileResultType', ctx, { resultType: typeof reader.result, }) } const match = reader.result.match(dataURIRegex) if (!match?.groups) { throw runtimeErr('InvalidDataUri', ctx, { 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) signals.setValue(`${signalName}Mimes`, allMimes) signals.setValue(`${signalName}Names`, 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 runtimeErr('BindUnsupportedSignalType', ctx, { signalType: typeof current, }) } } for (const event of updateEvents) { el.addEventListener(event, el2sig) } const elSigClean = effect(() => setFromSignal()) return () => { elSigClean() for (const event of updateEvents) { 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 { type 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 = 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 { type AttributePlugin, PluginType, Requirement, } from '../../../../engine/types' import { onElementRemoved } from '../../../../utils/dom' import { tagHas, tagToMs } from '../../../../utils/tags' import { kebabize } from '../../../../utils/text' import { debounce, delay, throttle } from '../../../../utils/timing' const 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], 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) { // Always prevent default on submit events (because forms) if (mods.has('prevent') || key === 'submit') evt.preventDefault() if (mods.has('stop')) evt.stopPropagation() } rx(evt) } const delayArgs = mods.get('delay') if (delayArgs) { const wait = tagToMs(delayArgs) const leading = tagHas(delayArgs, 'leading', false) callback = delay(callback, wait, leading) } const debounceArgs = mods.get('debounce') if (debounceArgs) { const wait = tagToMs(debounceArgs) const leading = tagHas(debounceArgs, 'leading', false) const trailing = !tagHas(debounceArgs, 'notrail', false) callback = debounce(callback, wait, leading, trailing) } const throttleArgs = mods.get('throttle') if (throttleArgs) { const wait = tagToMs(throttleArgs) const leading = !tagHas(throttleArgs, 'noleading', false) const trailing = tagHas(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 'interval': { let delay = 1000 if (delayArgs) { delay = tagToMs(delayArgs) // Run the callback now to counter the `delay` timing function wrapping the `callback` with the `setTimeout` function callback() } const intervalId = setInterval(callback, delay) return () => { clearInterval(intervalId) } } 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 const targetOutsideCallback = (e?: Event) => { const targetHTML = e?.target as HTMLElement if (!el.contains(targetHTML)) { cb(e) } } 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 { type AttributePlugin, PluginType, Requirement, } from '../../../../engine/types' import { trimDollarSignPrefix } from '../../../../utils/text' export const Ref: AttributePlugin = { type: PluginType.Attribute, name: 'ref', keyReq: Requirement.Exclusive, valReq: Requirement.Exclusive, onLoad: ({ el, key, value, signals }) => { const signalName = key ? key : trimDollarSignPrefix(value) signals.setValue(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 { runtimeErr } from '../../../../engine/errors' import { type 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)) { runtimeErr('TextInvalidElement', ctx) } 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 { type ActionPlugin, PluginType, type 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 { type 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 { type 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 }) }, } |