From 8cc9b7237b9e351bd4a9c1749909e68937d705dc Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 18:42:32 +0900 Subject: [PATCH 01/62] feat(alpine): create alpine-ts example --- examples/alpine-ts/.gitignore | 8 +++ examples/alpine-ts/README.md | 19 +++++ examples/alpine-ts/app/app.ts | 11 +++ examples/alpine-ts/app/assets/main.css | 96 +++++++++++++++++++++++++ examples/alpine-ts/app/assets/nitro.svg | 43 +++++++++++ examples/alpine-ts/app/assets/vite.svg | 1 + examples/alpine-ts/app/main.ts | 3 + examples/alpine-ts/index.html | 33 +++++++++ examples/alpine-ts/nitro.config.ts | 5 ++ examples/alpine-ts/package.json | 12 ++++ examples/alpine-ts/public/robots.txt | 2 + examples/alpine-ts/server/api/hello.ts | 5 ++ examples/alpine-ts/tsconfig.json | 8 +++ examples/alpine-ts/vite.config.ts | 6 ++ 14 files changed, 252 insertions(+) create mode 100644 examples/alpine-ts/.gitignore create mode 100644 examples/alpine-ts/README.md create mode 100644 examples/alpine-ts/app/app.ts create mode 100644 examples/alpine-ts/app/assets/main.css create mode 100644 examples/alpine-ts/app/assets/nitro.svg create mode 100644 examples/alpine-ts/app/assets/vite.svg create mode 100644 examples/alpine-ts/app/main.ts create mode 100644 examples/alpine-ts/index.html create mode 100644 examples/alpine-ts/nitro.config.ts create mode 100644 examples/alpine-ts/package.json create mode 100644 examples/alpine-ts/public/robots.txt create mode 100644 examples/alpine-ts/server/api/hello.ts create mode 100644 examples/alpine-ts/tsconfig.json create mode 100644 examples/alpine-ts/vite.config.ts diff --git a/examples/alpine-ts/.gitignore b/examples/alpine-ts/.gitignore new file mode 100644 index 0000000000..d547d8a265 --- /dev/null +++ b/examples/alpine-ts/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +.data +.nitro +.cache +.output +.env +.env.local diff --git a/examples/alpine-ts/README.md b/examples/alpine-ts/README.md new file mode 100644 index 0000000000..fddea78b59 --- /dev/null +++ b/examples/alpine-ts/README.md @@ -0,0 +1,19 @@ +# Nitro starter + +Create your full-stack apps and deploy it anywhere with this [Vite](https://vite.dev/) + [Nitro](https://v3.nitro.build/) starter. + +## Getting started + +```bash +npm install +npm run dev +``` + +## Deploying + +```bash +npm run build +npm run preview +``` + +Then checkout the [Nitro documentation](https://v3.nitro.build/deploy) to learn more about the different deployment presets. diff --git a/examples/alpine-ts/app/app.ts b/examples/alpine-ts/app/app.ts new file mode 100644 index 0000000000..3924ad5f82 --- /dev/null +++ b/examples/alpine-ts/app/app.ts @@ -0,0 +1,11 @@ +export function setupApp(element: HTMLButtonElement) { + const button = document.createElement("button") + + button.textContent = "Click me to call /api/hello" + element.appendChild(button) + + button.addEventListener("click", async () => { + const res = await fetch("/api/hello") + button.innerHTML = await res.text() + }) +} diff --git a/examples/alpine-ts/app/assets/main.css b/examples/alpine-ts/app/assets/main.css new file mode 100644 index 0000000000..32cef36b5f --- /dev/null +++ b/examples/alpine-ts/app/assets/main.css @@ -0,0 +1,96 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #ff2056; + text-decoration: inherit; +} +a:hover { + color: #ff637e; +} + +body { + margin: 0; + display: flex; + flex-direction: column; + place-items: center; + justify-content: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; + transition: transform 300ms; +} +.logo:hover { + transform: scale(1.1); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/examples/alpine-ts/app/assets/nitro.svg b/examples/alpine-ts/app/assets/nitro.svg new file mode 100644 index 0000000000..cdeec33d26 --- /dev/null +++ b/examples/alpine-ts/app/assets/nitro.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/alpine-ts/app/assets/vite.svg b/examples/alpine-ts/app/assets/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/examples/alpine-ts/app/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/alpine-ts/app/main.ts b/examples/alpine-ts/app/main.ts new file mode 100644 index 0000000000..fd00360bd0 --- /dev/null +++ b/examples/alpine-ts/app/main.ts @@ -0,0 +1,3 @@ +import { setupApp } from "./app" + +setupApp(document.querySelector("#app")!) diff --git a/examples/alpine-ts/index.html b/examples/alpine-ts/index.html new file mode 100644 index 0000000000..645200e0a1 --- /dev/null +++ b/examples/alpine-ts/index.html @@ -0,0 +1,33 @@ + + + + + + + Nitro + Vite + + + +
+
+
+ + + + + + +

Nitro + Vite

+
+
+
+ +
+ + diff --git a/examples/alpine-ts/nitro.config.ts b/examples/alpine-ts/nitro.config.ts new file mode 100644 index 0000000000..c3d0bb9980 --- /dev/null +++ b/examples/alpine-ts/nitro.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "nitro" + +export default defineConfig({ + serverDir: "./server", +}) diff --git a/examples/alpine-ts/package.json b/examples/alpine-ts/package.json new file mode 100644 index 0000000000..2a69cd4604 --- /dev/null +++ b/examples/alpine-ts/package.json @@ -0,0 +1,12 @@ +{ + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite dev", + "preview": "vite preview" + }, + "devDependencies": { + "nitro": "latest", + "vite": "npm:rolldown-vite" + } +} diff --git a/examples/alpine-ts/public/robots.txt b/examples/alpine-ts/public/robots.txt new file mode 100644 index 0000000000..eb0536286f --- /dev/null +++ b/examples/alpine-ts/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/examples/alpine-ts/server/api/hello.ts b/examples/alpine-ts/server/api/hello.ts new file mode 100644 index 0000000000..e7ab32670e --- /dev/null +++ b/examples/alpine-ts/server/api/hello.ts @@ -0,0 +1,5 @@ +import { defineHandler } from "nitro/h3" + +export default defineHandler((event) => { + return { api: "works!" } +}) diff --git a/examples/alpine-ts/tsconfig.json b/examples/alpine-ts/tsconfig.json new file mode 100644 index 0000000000..1099389dd3 --- /dev/null +++ b/examples/alpine-ts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": ["nitro/tsconfig"], + "compilerOptions": { + "paths": { + "~/*": ["./*"] + } + } +} diff --git a/examples/alpine-ts/vite.config.ts b/examples/alpine-ts/vite.config.ts new file mode 100644 index 0000000000..e6077b93fd --- /dev/null +++ b/examples/alpine-ts/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite" +import { nitro } from "nitro/vite" + +export default defineConfig({ + plugins: [nitro()], +}) From 7370f624a4bb2cb7a788040120c1d14927b1eacb Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 19:22:52 +0900 Subject: [PATCH 02/62] chore: add example deps --- examples/alpine-ts/package.json | 5 +++++ examples/alpine-ts/tsconfig.json | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/examples/alpine-ts/package.json b/examples/alpine-ts/package.json index 2a69cd4604..7183c9f196 100644 --- a/examples/alpine-ts/package.json +++ b/examples/alpine-ts/package.json @@ -6,7 +6,12 @@ "preview": "vite preview" }, "devDependencies": { + "@types/alpinejs": "^3.13.11", "nitro": "latest", "vite": "npm:rolldown-vite" + }, + "dependencies": { + "alpinejs": "^3.15.2", + "mono-jsx": "0.8.0-beta.4" } } diff --git a/examples/alpine-ts/tsconfig.json b/examples/alpine-ts/tsconfig.json index 1099389dd3..7d49f91d39 100644 --- a/examples/alpine-ts/tsconfig.json +++ b/examples/alpine-ts/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "paths": { "~/*": ["./*"] - } + }, + "jsx": "react-jsx", + "jsxImportSource": "mono-jsx" } } From ebe6797321e78b7bb225eeae55bec24d20836aad Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 19:24:50 +0900 Subject: [PATCH 03/62] chore: add Head component --- examples/alpine-ts/server/components/Head.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 examples/alpine-ts/server/components/Head.tsx diff --git a/examples/alpine-ts/server/components/Head.tsx b/examples/alpine-ts/server/components/Head.tsx new file mode 100644 index 0000000000..7acf914605 --- /dev/null +++ b/examples/alpine-ts/server/components/Head.tsx @@ -0,0 +1,11 @@ +export default function Head() { + return ( + + + + + Nitro + Vite + + + ) +} From 421ada10637aceec97cda6a99a9fdaffa9d5a05c Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 22:26:09 +0900 Subject: [PATCH 04/62] feat(alpine): add lib files --- examples/alpine-ts/lib/bindable.ts | 68 +++++ examples/alpine-ts/lib/index.ts | 1 + examples/alpine-ts/lib/machine.ts | 298 ++++++++++++++++++++++ examples/alpine-ts/lib/normalize-props.ts | 29 +++ examples/alpine-ts/lib/plugin.ts | 94 +++++++ examples/alpine-ts/lib/refs.ts | 11 + examples/alpine-ts/lib/track.ts | 21 ++ 7 files changed, 522 insertions(+) create mode 100644 examples/alpine-ts/lib/bindable.ts create mode 100644 examples/alpine-ts/lib/index.ts create mode 100644 examples/alpine-ts/lib/machine.ts create mode 100644 examples/alpine-ts/lib/normalize-props.ts create mode 100644 examples/alpine-ts/lib/plugin.ts create mode 100644 examples/alpine-ts/lib/refs.ts create mode 100644 examples/alpine-ts/lib/track.ts diff --git a/examples/alpine-ts/lib/bindable.ts b/examples/alpine-ts/lib/bindable.ts new file mode 100644 index 0000000000..9f5c30a34a --- /dev/null +++ b/examples/alpine-ts/lib/bindable.ts @@ -0,0 +1,68 @@ +import type { Bindable, BindableParams } from "@zag-js/core" +import { isFunction } from "@zag-js/utils" +import Alpine from "alpinejs" + +export function bindable(props: () => BindableParams): Bindable { + const initial = props().defaultValue ?? props().value + const eq = props().isEqual ?? Object.is + + const v = Alpine.reactive({ value: initial }) + const controlled = { + get value() { + return props().value !== undefined + }, + } + + const valueRef = { + get value() { + return controlled.value ? props().value : v.value + }, + } + + const setFn = (val: T | ((prev: T) => T)) => { + const prev = controlled.value ? props().value : v.value + const next = isFunction(val) ? val(prev as T) : val + + if (props().debug) { + console.log(`[bindable > ${props().debug}] setValue`, { next, prev }) + } + + if (!controlled.value) v.value = next + if (!eq(next, prev)) { + props().onChange?.(next, prev) + } + } + + function get(): T { + return (controlled.value ? props().value : v.value) as T + } + + return { + initial, + ref: valueRef, + get, + set(val: T | ((prev: T) => T)) { + setFn(val) + }, + invoke(nextValue: T, prevValue: T) { + props().onChange?.(nextValue, prevValue) + }, + hash(value: T) { + return props().hash?.(value) ?? String(value) + }, + } +} + +bindable.cleanup = (fn: VoidFunction) => { + Alpine.onElRemoved(() => fn()) +} + +bindable.ref = (defaultValue: T) => { + let value = defaultValue + return { + get: () => value, + set: (next: T) => { + value = next + }, + } +} diff --git a/examples/alpine-ts/lib/index.ts b/examples/alpine-ts/lib/index.ts new file mode 100644 index 0000000000..a03d003750 --- /dev/null +++ b/examples/alpine-ts/lib/index.ts @@ -0,0 +1 @@ +export { usePlugin } from "./plugin" diff --git a/examples/alpine-ts/lib/machine.ts b/examples/alpine-ts/lib/machine.ts new file mode 100644 index 0000000000..842a592769 --- /dev/null +++ b/examples/alpine-ts/lib/machine.ts @@ -0,0 +1,298 @@ +import type { + ActionsOrFn, + BindableContext, + ChooseFn, + ComputedFn, + EffectsOrFn, + GuardFn, + Machine, + MachineSchema, + Params, + Service, +} from "@zag-js/core" +import { createScope, INIT_STATE, MachineStatus } from "@zag-js/core" +import { compact, ensure, isFunction, isString, toArray, warn } from "@zag-js/utils" +import { bindable } from "./bindable" +import { useRefs } from "./refs" +import { track } from "./track" +import Alpine from "alpinejs" + +export function useMachine( + machine: Machine, + evaluateProps: (callback: (props: Partial) => void) => void, +): Service & { init: VoidFunction; destroy: VoidFunction } { + let initialProps = {} as T["props"] + evaluateProps((props) => { + initialProps = props + }) + // TODO: cache and update scope + const { id, ids, getRootNode } = initialProps as any + const scope = createScope({ id, ids, getRootNode }) + + const debug = (...args: any[]) => { + if (machine.debug) console.log(...args) + } + + const props = Alpine.reactive({ + value: machine.props?.({ props: compact(initialProps), scope }) ?? initialProps, + }) + Alpine.effect(() => { + let p = {} + evaluateProps((value) => (p = value)) + props.value = machine.props?.({ props: compact(p), scope }) ?? (p as any) + }) + const prop = useProp(props) + + const context: any = machine.context?.({ + prop, + bindable, + scope, + flush, + getContext() { + return ctx + }, + getComputed() { + return computed + }, + getRefs() { + return refs + }, + getEvent() { + return getEvent() + }, + }) + + const ctx: BindableContext = { + get(key) { + return context[key]?.get() + }, + set(key, value) { + context[key]?.set(value) + }, + initial(key) { + return context[key]?.initial + }, + hash(key) { + const current = context[key]?.get() + return context[key]?.hash(current) + }, + } + + let effects = new Map() + let transitionRef: any = null + + let previousEventRef: { current: any } = { current: null } + let eventRef: { current: any } = { current: { type: "" } } + + const getEvent = () => ({ + ...eventRef.current, + current() { + return eventRef.current + }, + previous() { + return previousEventRef.current + }, + }) + + const getState = () => ({ + ...state, + matches(...values: T["state"][]) { + const currentState = state.get() + return values.includes(currentState) + }, + hasTag(tag: T["tag"]) { + const currentState = state.get() + return !!machine.states[currentState]?.tags?.includes(tag) + }, + }) + + const refs = useRefs(machine.refs?.({ prop, context: ctx }) ?? {}) + + const getParams = (): Params => ({ + state: getState(), + context: ctx, + event: getEvent(), + prop, + send, + action, + guard, + track, + refs, + computed, + flush, + scope, + choose, + }) + + const action = (keys: ActionsOrFn | undefined) => { + const strs = isFunction(keys) ? keys(getParams()) : keys + if (!strs) return + const fns = strs.map((s) => { + const fn = machine.implementations?.actions?.[s] + if (!fn) warn(`[zag-js] No implementation found for action "${JSON.stringify(s)}"`) + return fn + }) + for (const fn of fns) { + fn?.(getParams()) + } + } + const guard = (str: T["guard"] | GuardFn) => { + if (isFunction(str)) return str(getParams()) + return machine.implementations?.guards?.[str](getParams()) + } + + const effect = (keys: EffectsOrFn | undefined) => { + const strs = isFunction(keys) ? keys(getParams()) : keys + if (!strs) return + const fns = strs.map((s) => { + const fn = machine.implementations?.effects?.[s] + if (!fn) warn(`[zag-js] No implementation found for effect "${JSON.stringify(s)}"`) + return fn + }) + const cleanups: VoidFunction[] = [] + for (const fn of fns) { + const cleanup = fn?.(getParams()) + if (cleanup) cleanups.push(cleanup) + } + return () => cleanups.forEach((fn) => fn?.()) + } + + const choose: ChooseFn = (transitions) => { + return toArray(transitions).find((t) => { + let result = !t.guard + if (isString(t.guard)) result = !!guard(t.guard) + else if (isFunction(t.guard)) result = t.guard(getParams()) + return result + }) + } + + const computed: ComputedFn = (key) => { + ensure(machine.computed, () => `[zag-js] No computed object found on machine`) + const fn = machine.computed[key] + return fn({ + context: ctx, + event: getEvent(), + prop, + refs, + scope, + computed: computed as any, + }) + } + + const state = bindable(() => ({ + defaultValue: machine.initialState({ prop }), + onChange(nextState, prevState) { + // compute effects: exit -> transition -> enter + + queueMicrotask(() => { + // exit effects + if (prevState) { + const exitEffects = effects.get(prevState) + exitEffects?.() + effects.delete(prevState) + } + + // exit actions + if (prevState) { + action(machine.states[prevState]?.exit) + } + + // transition actions + action(transitionRef?.actions) + + // enter effect + const cleanup = effect(machine.states[nextState]?.effects) + if (cleanup) effects.set(nextState as string, cleanup) + + // root entry actions + if (prevState === INIT_STATE) { + action(machine.entry) + const cleanup = effect(machine.effects) + if (cleanup) effects.set(INIT_STATE, cleanup) + } + + // enter actions + action(machine.states[nextState]?.entry) + }) + }, + })) + + let status = MachineStatus.NotStarted + + const init = () => { + const started = status === MachineStatus.Started + status = MachineStatus.Started + debug(started ? "rehydrating..." : "initializing...") + state.invoke(state.initial!, INIT_STATE) + } + + const destroy = () => { + debug("unmounting...") + status = MachineStatus.Stopped + + effects.forEach((fn) => fn?.()) + effects = new Map() + transitionRef.current = null + + action(machine.exit) + } + + const send = (event: any) => { + if (status !== MachineStatus.Started) return + + previousEventRef.current = eventRef.current + eventRef.current = event + + let currentState = state.get() + + // @ts-ignore + const transitions = machine.states[currentState].on?.[event.type] ?? machine.on?.[event.type] + + const transition = choose(transitions) + if (!transition) return + + // save current transition + transitionRef = transition + const target = transition.target ?? currentState + + debug("transition", event.type, transition.target || currentState, `(${transition.actions})`) + + const changed = target !== currentState + if (changed) { + // state change is high priority + state.set(target) + } else if (transition.reenter && !changed) { + // reenter will re-invoke the current state + state.invoke(currentState, currentState) + } else { + // call transition actions + action(transition.actions) + } + } + + machine.watch?.(getParams()) + + return { + state: getState(), + send, + context: ctx, + prop, + scope, + refs, + computed, + event: getEvent(), + getStatus: () => status, + init, + destroy, + } +} + +function useProp(ref: { value: T }) { + return function get(key: K): T[K] { + return ref.value[key] + } +} + +function flush(fn: VoidFunction) { + queueMicrotask(() => fn()) +} diff --git a/examples/alpine-ts/lib/normalize-props.ts b/examples/alpine-ts/lib/normalize-props.ts new file mode 100644 index 0000000000..5c76b1adfe --- /dev/null +++ b/examples/alpine-ts/lib/normalize-props.ts @@ -0,0 +1,29 @@ +import { createNormalizer } from "@zag-js/types" + +const propMap: Record = { + htmlFor: "for", + className: "class", + onDoubleClick: "onDblclick", + onChange: "onInput", + onFocus: "onFocusin", + onBlur: "onFocusout", + defaultValue: "value", + defaultChecked: "checked", +} + +export const normalizeProps = createNormalizer((props) => { + const normalized: Record any> = {} + for (const key in props) { + const prop = key in propMap ? propMap[key] : key + const value = props[key] + + if (prop === "children") { + normalized["x-html"] = () => value + } else if (prop.startsWith("on")) { + normalized["@" + prop.substring(2).toLowerCase()] = value + } else { + normalized[":" + prop.toLowerCase()] = () => value + } + } + return normalized +}) diff --git a/examples/alpine-ts/lib/plugin.ts b/examples/alpine-ts/lib/plugin.ts new file mode 100644 index 0000000000..4a3d0a2f45 --- /dev/null +++ b/examples/alpine-ts/lib/plugin.ts @@ -0,0 +1,94 @@ +import type { Machine, MachineSchema, Service } from "@zag-js/core" +import type { NormalizeProps, PropTypes } from "@zag-js/types" +import type { Alpine } from "alpinejs" +import { useMachine } from "./machine" +import { normalizeProps } from "./normalize-props" + +export function usePlugin( + name: string, + component: { + machine: Machine + connect: (service: Service, normalizeProps: NormalizeProps) => any + collection?: (options: any) => any + }, +) { + const underscore = name.replaceAll("-", "_") + const serviceName = `_x_${underscore}_service` as const + const api = `_x_${underscore}_api` as const + + return function (Alpine: Alpine) { + Alpine.directive(name, (el, { expression, value }, { cleanup, effect, evaluateLater }) => { + if (!value) { + const evaluateProps = evaluateLater(expression) as any + const service = useMachine(component.machine, evaluateProps) + Alpine.bind(el, { + "x-data"() { + return { + [serviceName]: service, // dev only, for state visualization + [api]: component.connect(service, normalizeProps), + init() { + queueMicrotask(() => { + effect(() => { + this[api] = component.connect(service, normalizeProps) + }) + }) + service.init() + }, + destroy() { + service.destroy() + }, + } + }, + }) + } else if (value === "collection") { + const evaluateCollection = evaluateLater(expression) + const cleanupBinding = Alpine.bind(el, { + "x-data"() { + return { + get collection() { + let options: any = {} + evaluateCollection((value) => (options = value)) + return component.collection?.(options) + }, + } + }, + }) + cleanup(() => cleanupBinding()) + } else { + const getProps = `get${value + .split("-") + .map((v) => v.at(0)?.toUpperCase() + v.substring(1).toLowerCase()) + .join("")}Props` + const evaluateProps = expression ? evaluateLater(expression) : null + + let props = {} + evaluateProps && evaluateProps((value: any) => (props = value)) + const ref = Alpine.reactive({ ...(Alpine.$data(el) as any)[api][getProps](props) }) + + const binding: Record any> = {} + for (const prop in ref) { + binding[prop] = (...args: any[]) => ref[prop]?.(...args) + } + Alpine.bind(el, binding) + + effect(() => { + let props = {} + evaluateProps && evaluateProps((value: any) => (props = value)) + const next = (Alpine.$data(el) as any)[api][getProps](props) + for (const prop in next) { + if (prop.startsWith("@") || next[prop]() !== ref[prop]()) { + ref[prop] = next[prop] + } + } + }) + } + }).before("bind") + Alpine.magic( + name + .split("-") + .map((str, i) => (i === 0 ? str : str.at(0)?.toUpperCase() + str.substring(1).toLowerCase())) + .join(""), + (el) => (Alpine.$data(el) as any)[api], + ) + } +} diff --git a/examples/alpine-ts/lib/refs.ts b/examples/alpine-ts/lib/refs.ts new file mode 100644 index 0000000000..07ee8cc2fa --- /dev/null +++ b/examples/alpine-ts/lib/refs.ts @@ -0,0 +1,11 @@ +export function useRefs(refs: T) { + const ref = { current: refs } + return { + get(key: K): T[K] { + return ref.current[key] + }, + set(key: K, value: T[K]) { + ref.current[key] = value + }, + } +} diff --git a/examples/alpine-ts/lib/track.ts b/examples/alpine-ts/lib/track.ts new file mode 100644 index 0000000000..c0e3253a70 --- /dev/null +++ b/examples/alpine-ts/lib/track.ts @@ -0,0 +1,21 @@ +import { isEqual } from "@zag-js/utils" +import Alpine from "alpinejs" + +export const track = (deps: any[], effect: VoidFunction) => { + // @ts-ignore @types/alpinejs is outdated + Alpine.watch( + () => [...deps.map((d) => d())], + (current: any[], previous: any[]) => { + let changed = false + for (let i = 0; i < current.length; i++) { + if (!isEqual(previous[i], current[i])) { + changed = true + break + } + } + if (changed) { + effect() + } + }, + ) +} From fb94ac120997bd78d97c9e2222fa784ad3a09d6e Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 22:58:20 +0900 Subject: [PATCH 05/62] feat(alpine): add Nav component --- examples/alpine-ts/server/components/Nav.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 examples/alpine-ts/server/components/Nav.tsx diff --git a/examples/alpine-ts/server/components/Nav.tsx b/examples/alpine-ts/server/components/Nav.tsx new file mode 100644 index 0000000000..deb1f9b433 --- /dev/null +++ b/examples/alpine-ts/server/components/Nav.tsx @@ -0,0 +1,20 @@ +import { dataAttr } from "@zag-js/dom-query" +import { routesData } from "@zag-js/shared" + +export default function Nav({ pathname }: { pathname: string }) { + return ( + + ) +} From e7c065431ef57392456d67f81c092ca574326eed Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 23:19:48 +0900 Subject: [PATCH 06/62] feat(alpine): add style script --- examples/alpine-ts/scripts/style.ts | 1 + examples/alpine-ts/server/components/Head.tsx | 1 + 2 files changed, 2 insertions(+) create mode 100644 examples/alpine-ts/scripts/style.ts diff --git a/examples/alpine-ts/scripts/style.ts b/examples/alpine-ts/scripts/style.ts new file mode 100644 index 0000000000..3b860d0815 --- /dev/null +++ b/examples/alpine-ts/scripts/style.ts @@ -0,0 +1 @@ +import "@zag-js/shared/src/style.css" diff --git a/examples/alpine-ts/server/components/Head.tsx b/examples/alpine-ts/server/components/Head.tsx index 7acf914605..86640d76e9 100644 --- a/examples/alpine-ts/server/components/Head.tsx +++ b/examples/alpine-ts/server/components/Head.tsx @@ -4,6 +4,7 @@ export default function Head() { + Nitro + Vite From 79d39003ca00b2fda069caf3d1c92ff7e62ad89d Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 23:20:15 +0900 Subject: [PATCH 07/62] feat(alpine): add deps --- examples/alpine-ts/package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/alpine-ts/package.json b/examples/alpine-ts/package.json index 7183c9f196..40341daa70 100644 --- a/examples/alpine-ts/package.json +++ b/examples/alpine-ts/package.json @@ -11,6 +11,12 @@ "vite": "npm:rolldown-vite" }, "dependencies": { + "@zag-js/accordion": "workspace:*", + "@zag-js/core": "workspace:*", + "@zag-js/dom-query": "workspace:*", + "@zag-js/shared": "workspace:*", + "@zag-js/types": "workspace:*", + "@zag-js/utils": "workspace:*", "alpinejs": "^3.15.2", "mono-jsx": "0.8.0-beta.4" } From 84c59be543fa2d000c0aecc177d42175e3d6399d Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 23:40:44 +0900 Subject: [PATCH 08/62] chore: add lucide static --- examples/alpine-ts/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/alpine-ts/package.json b/examples/alpine-ts/package.json index 40341daa70..3cd4ab01c4 100644 --- a/examples/alpine-ts/package.json +++ b/examples/alpine-ts/package.json @@ -18,6 +18,7 @@ "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*", "alpinejs": "^3.15.2", + "lucide-static": "^0.554.0", "mono-jsx": "0.8.0-beta.4" } } From c2107f26cd6cb8cf2e7a9059e3c87b1e80d36376 Mon Sep 17 00:00:00 2001 From: nilpotential Date: Wed, 19 Nov 2025 23:42:05 +0900 Subject: [PATCH 09/62] feat(alpine): add accordion example --- examples/alpine-ts/scripts/accordion.ts | 6 +++ .../alpine-ts/server/routes/accordion.tsx | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 examples/alpine-ts/scripts/accordion.ts create mode 100644 examples/alpine-ts/server/routes/accordion.tsx diff --git a/examples/alpine-ts/scripts/accordion.ts b/examples/alpine-ts/scripts/accordion.ts new file mode 100644 index 0000000000..cdf48aa4d9 --- /dev/null +++ b/examples/alpine-ts/scripts/accordion.ts @@ -0,0 +1,6 @@ +import * as accordion from "@zag-js/accordion" +import Alpine from "alpinejs" +import { usePlugin } from "~/lib" + +Alpine.plugin(usePlugin("accordion", accordion)) +Alpine.start() diff --git a/examples/alpine-ts/server/routes/accordion.tsx b/examples/alpine-ts/server/routes/accordion.tsx new file mode 100644 index 0000000000..6a4963ed13 --- /dev/null +++ b/examples/alpine-ts/server/routes/accordion.tsx @@ -0,0 +1,47 @@ +import { defineHandler } from "nitro/h3" +import { accordionControls, accordionData, getControlDefaults } from "@zag-js/shared" +import { ArrowRight } from "lucide-static" +import Head from "~/server/components/Head" +import Nav from "~/server/components/Nav" + +export default defineHandler((event) => { + const controls = getControlDefaults(accordionControls) + + return ( + + + + + + +
+
+ + + ) +}) From f53cdb9050513421b1ebc221224b964495a545f1 Mon Sep 17 00:00:00 2001 From: nilpotential Date: Thu, 20 Nov 2025 10:01:11 +0900 Subject: [PATCH 10/62] feat(alpine): add alpine scripts --- package.json | 3 +++ playwright.config.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/package.json b/package.json index 56b10a8cb7..554e01301b 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,12 @@ "vue": "pnpm --filter \"./examples/nuxt-ts\"", "solid": "pnpm --filter \"./examples/solid-ts\"", "svelte": "pnpm --filter \"./examples/svelte-ts\"", + "alpine": "pnpm --filter \"./examples/alpine-ts\"", "start-react": "pnpm react dev", "start-vue": "pnpm vue dev", "start-solid": "pnpm solid dev", "start-svelte": "pnpm svelte dev", + "start-alpine": "pnpm alpine dev", "start-website": "pnpm website dev", "pw-report": "playwright show-report e2e/report", "pw-test": "cross-env FRAMEWORK=react playwright test", @@ -30,6 +32,7 @@ "e2e-react": "cross-env FRAMEWORK=react playwright test", "e2e-vue": "cross-env FRAMEWORK=vue playwright test", "e2e-solid": "cross-env FRAMEWORK=solid playwright test", + "e2e-alpine": "cross-env FRAMEWORK=alpine playwright test", "generate-machine": "plop machine && pnpm sync-pkgs", "generate-util": "plop utility && pnpm sync-pkgs", "typecheck": "pnpm packages -- typecheck", diff --git a/playwright.config.ts b/playwright.config.ts index e68b5c87ef..bc89f18178 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -34,6 +34,12 @@ export function getWebServer(): WebServer { url: "http://localhost:3003", reuseExistingServer: !CI, }, + alpine: { + cwd: "./examples/alpine-ts", + command: "pnpm vite --port 3005", + url: "http://localhost:3005", + reuseExistingServer: !CI, + }, } return frameworks[framework] From c10c71f4ed2915b25b15e2af932a3b7f338059ad Mon Sep 17 00:00:00 2001 From: nilpotential Date: Thu, 20 Nov 2025 10:02:58 +0900 Subject: [PATCH 11/62] feat(alpine): add toolbar components --- examples/alpine-ts/package.json | 1 + .../alpine-ts/scripts/state-visualizer.ts | 17 ++++++ examples/alpine-ts/scripts/toolbar.ts | 6 ++ .../alpine-ts/server/components/controls.tsx | 58 +++++++++++++++++++ .../server/components/{Head.tsx => head.tsx} | 4 +- .../server/components/{Nav.tsx => nav.tsx} | 2 +- .../server/components/state-visualizer.tsx | 18 ++++++ .../alpine-ts/server/components/toolbar.tsx | 26 +++++++++ 8 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 examples/alpine-ts/scripts/state-visualizer.ts create mode 100644 examples/alpine-ts/scripts/toolbar.ts create mode 100644 examples/alpine-ts/server/components/controls.tsx rename examples/alpine-ts/server/components/{Head.tsx => head.tsx} (66%) rename examples/alpine-ts/server/components/{Nav.tsx => nav.tsx} (87%) create mode 100644 examples/alpine-ts/server/components/state-visualizer.tsx create mode 100644 examples/alpine-ts/server/components/toolbar.tsx diff --git a/examples/alpine-ts/package.json b/examples/alpine-ts/package.json index 3cd4ab01c4..6aaf9465e9 100644 --- a/examples/alpine-ts/package.json +++ b/examples/alpine-ts/package.json @@ -15,6 +15,7 @@ "@zag-js/core": "workspace:*", "@zag-js/dom-query": "workspace:*", "@zag-js/shared": "workspace:*", + "@zag-js/stringify-state": "workspace:*", "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*", "alpinejs": "^3.15.2", diff --git a/examples/alpine-ts/scripts/state-visualizer.ts b/examples/alpine-ts/scripts/state-visualizer.ts new file mode 100644 index 0000000000..5b0322212d --- /dev/null +++ b/examples/alpine-ts/scripts/state-visualizer.ts @@ -0,0 +1,17 @@ +import { highlightState } from "@zag-js/stringify-state" +import Alpine from "alpinejs" + +Alpine.magic("highlightState", (el) => { + return ({ label, omit, context }: { label: string; omit?: string[]; context?: string[] }) => { + const { [`_x_${label.replaceAll("-", "_")}_service`]: service } = Alpine.$data(el) as any + return highlightState( + { + state: service.state.get(), + event: service.event.current(), + previouseEvent: service.event.previous(), + context: context ? Object.fromEntries(context.map((key) => [key, service.context.get(key)])) : undefined, + }, + omit, + ) + } +}) diff --git a/examples/alpine-ts/scripts/toolbar.ts b/examples/alpine-ts/scripts/toolbar.ts new file mode 100644 index 0000000000..124f6a705b --- /dev/null +++ b/examples/alpine-ts/scripts/toolbar.ts @@ -0,0 +1,6 @@ +import { dataAttr } from "@zag-js/dom-query" +import Alpine from "alpinejs" + +Alpine.magic("dataAttr", () => { + return (guard: boolean | undefined) => dataAttr(guard) +}) diff --git a/examples/alpine-ts/server/components/controls.tsx b/examples/alpine-ts/server/components/controls.tsx new file mode 100644 index 0000000000..09232d370c --- /dev/null +++ b/examples/alpine-ts/server/components/controls.tsx @@ -0,0 +1,58 @@ +import { deepGet, type ControlRecord, type ControlValue } from "@zag-js/shared" + +interface ControlsProps { + config: T + state: ControlValue +} + +export function Controls({ config, state }: ControlsProps) { + return ( +
+ {Object.keys(config).map((key) => { + const { type, label = key, options, placeholder, min, max } = (config[key] ?? {}) as any + const value = deepGet(state, key) + switch (type) { + case "boolean": + return ( +
+ + +
+ ) + case "string": + return ( +
+ + +
+ ) + case "select": + return ( +
+ + +
+ ) + case "number": + return ( +
+ + +
+ ) + default: + return null + } + })} +
+ ) +} diff --git a/examples/alpine-ts/server/components/Head.tsx b/examples/alpine-ts/server/components/head.tsx similarity index 66% rename from examples/alpine-ts/server/components/Head.tsx rename to examples/alpine-ts/server/components/head.tsx index 86640d76e9..9bb7b20397 100644 --- a/examples/alpine-ts/server/components/Head.tsx +++ b/examples/alpine-ts/server/components/head.tsx @@ -1,9 +1,11 @@ -export default function Head() { +export function Head() { return ( + + Nitro + Vite diff --git a/examples/alpine-ts/server/components/Nav.tsx b/examples/alpine-ts/server/components/nav.tsx similarity index 87% rename from examples/alpine-ts/server/components/Nav.tsx rename to examples/alpine-ts/server/components/nav.tsx index deb1f9b433..9567109f0e 100644 --- a/examples/alpine-ts/server/components/Nav.tsx +++ b/examples/alpine-ts/server/components/nav.tsx @@ -1,7 +1,7 @@ import { dataAttr } from "@zag-js/dom-query" import { routesData } from "@zag-js/shared" -export default function Nav({ pathname }: { pathname: string }) { +export function Nav({ pathname }: { pathname: string }) { return (