GuideReferenceHow TosExamples
Bundler
While Datastar is still one of the smallest frameworks available, you can bundle only the plugins you need to reduce the size even further.
Plugins
official/backend/actions
DELETE
Use a DELETE request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface
NameDELETE
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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 })
  },
}
GET
Use a GET request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface
NameGET
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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 })
  },
}
PATCH
Use a PATCH request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface
NamePATCH
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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 })
  },
}
POST
Use a POST request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface
NamePOST
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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 })
  },
}
PUT
Use a PUT request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface
NamePUT
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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 })
  },
}
official/backend/attributes
Indicator
Sets the indicator signal used when fetching data via SSE
NameIndicator
Descriptionmust be a valid signal name
Authors
Pathofficial/backend/attributes/indicator.ts
Source
import {
  type AttributePlugin,
  PluginType,
  Requirement,
} from '../../../../engine/types'
import { modifyCasing, 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: ({ el, key, mods, signals, value }) => {
    const signalName = key ? modifyCasing(key, mods) : 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)
    }
  },
}
official/backend/watchers
ExecuteScript
Execute JavaScript using a Server-Sent Event
NameExecuteScript
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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()
        }
      },
    )
  },
}
MergeFragments
Merge fragments into the DOM using a Server-Sent Event
NameMergeFragments
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/backend/watchers/mergeFragments.ts
Source
import {
  DefaultFragmentMergeMode,
  DefaultFragmentsSettleDurationMs,
  DefaultFragmentsUseViewTransitions,
  EventTypes,
  FragmentMergeModes,
} from '../../../../engine/consts'
import { initErr } from '../../../../engine/errors'
import {
  type HTMLorSVGElement,
  type InitContext,
  PluginType,
  type WatcherPlugin,
} from '../../../../engine/types'
import { attrHash, elUniqId, walkDOM } from '../../../../utils/dom'
import { isBoolString } from '../../../../utils/text'
import {
  docWithViewTransitionAPI,
  supportsViewTransitions,
} from '../../../../utils/view-transtions'
import { Idiomorph } from '../../../../vendored/idiomorph.esm'
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 (useViewTransition && supportsViewTransitions) {
            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
    const modifiedTarget = initialTarget
    switch (mergeMode) {
      case FragmentMergeModes.Morph: {
        const fragmentWithIDs = fragment.cloneNode(true) as HTMLorSVGElement
        walkDOM(fragmentWithIDs, (el) => {
          if (!el.id?.length && Object.keys(el.dataset).length) {
            el.id = elUniqId(el)
          }
          // Rehash the cleanup functions for this element to ensure that plugins are cleaned up and reapplied after merging.
          const elTracking = ctx.removals.get(el.id)
          if (elTracking) {
            const newElTracking = new Map()
            for (const [key, cleanup] of elTracking) {
              const newKey = attrHash(key, key)
              newElTracking.set(newKey, cleanup)
              elTracking.delete(key)
            }
            ctx.removals.set(el.id, newElTracking)
          }
        })
        Idiomorph.morph(modifiedTarget, fragmentWithIDs)
        break
      }
      case FragmentMergeModes.Inner:
        // Replace the contents of the target element with the outer HTML of the response
        modifiedTarget.innerHTML = fragment.outerHTML
        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 })
    }
    const cl = modifiedTarget.classList
    cl?.add(SWAPPING_CLASS)
    setTimeout(() => {
      initialTarget.classList.remove(SWAPPING_CLASS)
      cl?.remove(SWAPPING_CLASS)
    }, settleDuration)
    const revisedHTML = modifiedTarget.outerHTML
    if (cl && originalHTML !== revisedHTML) {
      cl.add(SETTLING_CLASS)
      setTimeout(() => {
        cl.remove(SETTLING_CLASS)
      }, settleDuration)
    }
  }
}
MergeSignals
Merge signals using a Server-Sent Event
NameMergeSignals
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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)
      },
    )
  },
}
RemoveFragments
Remove fragments from the DOM using a Server-Sent Event
NameRemoveFragments
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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 (useViewTransition && supportsViewTransitions) {
          docWithViewTransitionAPI.startViewTransition(() => applyToTargets())
        } else {
          applyToTargets()
        }
      },
    )
  },
}
RemoveSignals
Remove signals using a Server-Sent Event
NameRemoveSignals
DescriptionRemember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client.
Authors
Pathofficial/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)
      },
    )
  },
}
official/browser/actions
Clipboard
Copy text to the clipboard
NameClipboard
DescriptionThis action copies text to the clipboard using the Clipboard API.
AuthorsDelaney Gillilan
Pathofficial/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)
  },
}
official/browser/attributes
CustomValidity
Add custom validity to an element using an expression
NameCustomValidity
DescriptionThis 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.
AuthorsBen Croker
Pathofficial/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 || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement)) {
      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)
    })
  },
}
Intersects
Run expression when element intersects with viewport
NameIntersects
DescriptionAn attribute that runs an expression when the element intersects with the viewport.
AuthorsDelaney Gillilan
Pathofficial/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()
  },
}
Persist
Persist data to local storage or session storage
NamePersist
DescriptionThis 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.
AuthorsDelaney Gillilan
Pathofficial/browser/attributes/persist.ts
Source
import { DATASTAR } from '../../../../engine/consts'
import {
  type AttributePlugin,
  type NestedValues,
  PluginType,
} from '../../../../engine/types'
import { modifyCasing, trimDollarSignPrefix } from '../../../../utils/text'
const SESSION = 'session'
export const Persist: AttributePlugin = {
  type: PluginType.Attribute,
  name: 'persist',
  mods: new Set([SESSION]),
  onLoad: ({ key, effect, mods, signals, value }) => {
    key = modifyCasing(key, 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()
    })
  },
}
ReplaceUrl
Replace the current URL with a new URL
NameReplaceUrl
DescriptionThis 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.
AuthorsDelaney Gillilan
Pathofficial/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)
    })
  },
}
ScrollIntoView
Scroll an element into view
NameScrollIntoView
DescriptionThis attribute scrolls the element into view.
AuthorsDelaney Gillilan
Pathofficial/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]
  },
}
Show
Show or hide an element
NameShow
DescriptionThis 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.
AuthorsDelaney Gillilan
Pathofficial/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)
      }
    })
  },
}
ViewTransition
Setup view transitions
NameViewTransition
DescriptionThis 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.
AuthorsDelaney Gillilan
Pathofficial/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
    })
  },
}
official/dom/attributes
Attr
Bind attributes to expressions
NameAttr
DescriptionAny attribute can be bound to an expression. The attribute will be updated reactively whenever the expression signal changes.
AuthorsDelaney Gillilan
Pathofficial/dom/attributes/attr.ts
Source
import {
  type AttributePlugin,
  type NestedValues,
  PluginType,
  Requirement,
} from '../../../../engine/types'
import { kebab } from '../../../../utils/text'
export const Attr: AttributePlugin = {
  type: PluginType.Attribute,
  name: 'attr',
  valReq: Requirement.Must,
  onLoad: ({ el, key, effect, genRX }) => {
    const rx = genRX()
    if (key === '') {
      return effect(async () => {
        const binds = rx<NestedValues>()
        for (const [key, val] of Object.entries(binds)) {
          if (val === false) {
            el.removeAttribute(key)
          } else {
            el.setAttribute(key, val)
          }
        }
      })
    }
    // Attributes are always kebab-case
    key = kebab(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)
      }
    })
  },
}
Bind
Bind attributes to expressions
NameBind
DescriptionAny attribute can be bound to an expression. The attribute will be updated reactively whenever the expression signal changes.
AuthorsDelaney Gillilan
Pathofficial/dom/attributes/bind.ts
Source
import { runtimeErr } from '../../../../engine/errors'
import {
  type AttributePlugin,
  PluginType,
  Requirement,
} from '../../../../engine/types'
import { modifyCasing, 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, key, mods, signals, value, effect } = ctx
    const input = el as HTMLInputElement
    const signalName = key
      ? modifyCasing(key, mods)
      : trimDollarSignPrefix(value)
      
    const tnl = el.tagName.toLowerCase()
    const isInput = tnl.includes('input')
    const isSelect = tnl.includes('select')
    const type = el.getAttribute('type')
    const hasValueAttribute = el.hasAttribute('value')
    let signalDefault: string | boolean | number | File = ''
    const isCheckbox = isInput && type === 'checkbox'
    if (isCheckbox) {
      signalDefault = hasValueAttribute ? '' : false
    }
    const isNumber = isInput && type === 'number'
    if (isNumber) {
      signalDefault = 0
    }
    const isRadio = isInput && type === 'radio'
    if (isRadio) {
      const name = el.getAttribute('name')
      if (!name?.length) {
        el.setAttribute('name', signalName)
      }
    }
    // Can't set a default value for a file input, yet
    const isFile = isInput && type === 'file'
    const { signal, inserted } = signals.upsertIfMissing(
      signalName,
      signalDefault,
    )
    let arrayIndex = -1
    if (Array.isArray(signal.value)) {
      if (el.getAttribute('name') === null) {
        el.setAttribute('name', signalName)
      }
      arrayIndex = [
        ...document.querySelectorAll(`[name="${signalName}"]`),
      ].findIndex((el) => el === ctx.el)
    }
    const isArray = arrayIndex >= 0
    const signalArray = () => [...(signals.value(signalName) as any[])]
    const setElementFromSignal = () => {
      let value = signals.value(signalName)
      if (isArray && !isSelect) {
        // May be undefined if the array is shorter than the index
        value = (value as any)[arrayIndex] || signalDefault
      }
      const stringValue = `${value}`
      if (isCheckbox || isRadio) {
        if (typeof value === 'boolean') {
          input.checked = value
        } else {
          input.checked = stringValue === input.value
        }
      } else if (isSelect) {
        const select = el as HTMLSelectElement
        if (select.multiple) {
          if (!isArray) {
            throw runtimeErr('BindSelectMultiple', ctx)
          }
          for (const opt of select.options) {
            if (opt?.disabled) return
            const incoming = isNumber ? Number(opt.value) : opt.value
            opt.selected = (value as any[]).includes(incoming)
          }
        } else {
          select.value = stringValue
        }
      } else if (isFile) {
        // File input reading from a signal is not supported
      } else if ('value' in el) {
        el.value = stringValue
      } else {
        el.setAttribute('value', stringValue)
      }
    }
    const setSignalFromElement = async () => {
      let currentValue = signals.value(signalName)
      if (isArray) {
        // Push as many default signal values onto the array as necessary to reach the index
        const currentArray = currentValue as any[]
        while (arrayIndex >= currentArray.length) {
          currentArray.push(signalDefault)
        }
        currentValue = currentArray[arrayIndex] || signalDefault
      }
      const update = (signalName: string, value: any) => {
        let newValue = value
        if (isArray && !isSelect) {
          newValue = signalArray()
          newValue[arrayIndex] = value
        }
        signals.setValue(signalName, newValue)
      }
      // Files are a special flower
      if (isFile) {
        const files = [...(input?.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)
            })
          }),
        )
        update(signalName, allContents)
        update(`${signalName}Mimes`, allMimes)
        update(`${signalName}Names`, allNames)
        return
      }
      const value = input.value || ''
      let newValue: any
      if (isCheckbox) {
        const checked =
          input.checked || input.getAttribute('checked') === 'true'
        // We must check for an attribute value because a checked value defaults to `on`.
        if (hasValueAttribute) {
          newValue = checked ? value : ''
        } else {
          newValue = checked
        }
      } else if (isSelect) {
        const select = el as HTMLSelectElement
        const selectedOptions = [...select.selectedOptions]
        if (isArray) {
          newValue = selectedOptions
            .filter((opt) => opt.selected)
            .map((opt) => opt.value)
        } else {
          newValue = selectedOptions[0]?.value || signalDefault
        }
      } else if (typeof currentValue === 'boolean') {
        newValue = Boolean(value)
      } else if (typeof currentValue === 'number') {
        newValue = Number(value)
      } else {
        newValue = value || ''
      }
      update(signalName, newValue)
    }
    // If the signal was inserted, attempt to set the the signal value from the element.
    if (inserted) {
      setSignalFromElement()
    }
    for (const event of updateEvents) {
      el.addEventListener(event, setSignalFromElement)
    }
    /*
     * The signal value needs to be updated after the "pageshow" event.
     * Sometimes, the browser might populate inputs with previous values
     * when navigating between pages using the back/forward navigation.
     *
     * For more information, read about bfcache:
     * https://web.dev/articles/bfcache
     */
    const onPageshow = (ev: PageTransitionEvent) => {
      if (!ev.persisted) return
      setSignalFromElement()
    }
    window.addEventListener('pageshow', onPageshow)
    const reset = effect(() => setElementFromSignal())
    return () => {
      reset()
      for (const event of updateEvents) {
        el.removeEventListener(event, setSignalFromElement)
      }
      
      window.removeEventListener('pageshow', onPageshow)
    }
  },
}
Class
Add or remove classes from an element reactively
NameClass
DescriptionThis 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.
AuthorsDelaney Gillilan
Pathofficial/dom/attributes/class.ts
Source
import {
  type AttributePlugin,
  PluginType,
  Requirement,
} from '../../../../engine/types'
import { modifyCasing } from '../../../../utils/text'
export const Class: AttributePlugin = {
  type: PluginType.Attribute,
  name: 'class',
  valReq: Requirement.Must,
  onLoad: ({ el, key, mods, effect, genRX }) => {
    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 {
        key = modifyCasing(key, mods)
        const shouldInclude = rx<boolean>()
        if (shouldInclude) {
          cl.add(key)
        } else {
          cl.remove(key)
        }
      }
    })
  },
}
On
Add an event listener to an element
NameOn
DescriptionThis 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.
AuthorsDelaney Gillilan
Pathofficial/dom/attributes/on.ts
Source
import {
  type AttributePlugin,
  DATASTAR_SIGNAL_EVENT,
  type DatastarSignalEvent,
  PluginType,
  Requirement,
} from '../../../../engine/types'
import { tagHas, tagToMs } from '../../../../utils/tags'
import { camel, modifyCasing } from '../../../../utils/text'
import { debounce, delay, throttle } from '../../../../utils/timing'
import { supportsViewTransitions } from '../../../../utils/view-transtions'
import type { Signal } from '../../../../vendored/preact-core'
const EVT = 'evt'
const SIGNALS_CHANGE_PREFIX = 'signalsChange'
const signalChangeKeyLength = SIGNALS_CHANGE_PREFIX.length
export const On: AttributePlugin = {
  type: PluginType.Attribute,
  name: 'on',
  keyReq: Requirement.Must,
  valReq: Requirement.Must,
  argNames: [EVT],
  onLoad: ({ el, key, mods, signals, effect, genRX }) => {
    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)
      callback = delay(callback, wait)
    }
    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)
    }
    if (mods.has('viewtransition') && supportsViewTransitions) {
      const cb = callback // I hate javascript
      callback = (...args: any[]) =>
        document.startViewTransition(() => cb(...args))
    }
    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
    if (key === 'load') {
      // Delay the callback to the next microtask so that indicators can be set
      setTimeout(callback, 0)
      return () => {}
    }
    if (key === 'interval') {
      let duration = 1000
      const durationArgs = mods.get('duration')
      if (durationArgs) {
        duration = tagToMs(durationArgs)
        const leading = tagHas(durationArgs, 'leading', false)
        if (leading) {
          callback()
        }
      }
      const intervalId = setInterval(callback, duration)
      return () => {
        clearInterval(intervalId)
      }
    }
    if (key === 'raf') {
      let rafId: number | undefined
      const raf = () => {
        callback()
        rafId = requestAnimationFrame(raf)
      }
      rafId = requestAnimationFrame(raf)
      return () => {
        if (rafId) cancelAnimationFrame(rafId)
      }
    }
    if (key.startsWith(SIGNALS_CHANGE_PREFIX)) {
      if (key === SIGNALS_CHANGE_PREFIX) {
        const signalFn = (event: CustomEvent<DatastarSignalEvent>) =>
          callback(event)
        document.addEventListener(DATASTAR_SIGNAL_EVENT, signalFn)
        return () => {
          document.removeEventListener(DATASTAR_SIGNAL_EVENT, signalFn)
        }
      }
      const signalPath = modifyCasing(
        camel(key.slice(signalChangeKeyLength)),
        mods,
      )
      const signalValues = new Map<Signal, any>()
      signals.walk((path, signal) => {
        if (path.startsWith(signalPath)) {
          signalValues.set(signal, signal.value)
        }
      })
      return effect(() => {
        for (const [signal, prev] of signalValues) {
          if (prev !== signal.value) {
            callback()
            signalValues.set(signal, signal.value)
          }
        }
      })
    }
    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
    }
    const eventName = modifyCasing(key, mods)
    target.addEventListener(eventName, callback, evtListOpts)
    return () => {
      target.removeEventListener(eventName, callback)
    }
  },
}
Ref
Create a reference to an element
NameRef
DescriptionThis attribute creates a reference to an element that can be used in other expressions.
AuthorsDelaney Gillilan
Pathofficial/dom/attributes/ref.ts
Source
import {
  type AttributePlugin,
  PluginType,
  Requirement,
} from '../../../../engine/types'
import { modifyCasing, trimDollarSignPrefix } from '../../../../utils/text'
export const Ref: AttributePlugin = {
  type: PluginType.Attribute,
  name: 'ref',
  keyReq: Requirement.Exclusive,
  valReq: Requirement.Exclusive,
  onLoad: ({ el, key, mods, signals, value }) => {
    const signalName = key ? modifyCasing(key, mods) : trimDollarSignPrefix(value)
    signals.setValue(signalName, el)
  },
}
Text
Set the text content of an element
NameText
DescriptionThis attribute sets the text content of an element to the result of the expression.
AuthorsDelaney Gillilan
Pathofficial/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, effect, genRX } = ctx
    const rx = genRX()
    if (!(el instanceof HTMLElement)) {
      runtimeErr('TextInvalidElement', ctx)
    }
    return effect(() => {
      const res = rx(ctx)
      el.textContent = `${res}`
    })
  },
}
official/logic/actions
Fit
Clamp a value to a new range
NameFit
DescriptionThis 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.
AuthorsDelaney Gillilan
Pathofficial/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
  },
}
SetAll
Set all signals that match a regular expression
NameSetAll
AuthorsDelaney Gillilan
Pathofficial/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
    })
  },
}
ToggleAll
Toggle all signals that match a regular expression
NameToggleAll
AuthorsDelaney Gillilan
Pathofficial/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
    })
  },
}
Advanced