From 9cef4e914229e1c398a466b51c2bbde7cca8972a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 25 Feb 2026 10:27:25 -0600 Subject: [PATCH] prepare v0.10 (#164) --- .changeset/config.json | 4 +- .changeset/v0-10-release.md | 74 ++++++++++++ README.md | 26 +++- apps/web/app/(main)/docs/api/react/page.mdx | 30 +++++ apps/web/app/(main)/docs/changelog/page.mdx | 113 +++++++++++++++++ .../web/app/(main)/docs/installation/page.mdx | 2 + apps/web/lib/page-titles.ts | 2 + packages/core/README.md | 114 ++++++++++++++++++ packages/react/README.md | 74 +++++++++++- packages/shadcn/README.md | 25 ++-- packages/{xstate-store => xstate}/README.md | 6 +- .../{xstate-store => xstate}/package.json | 4 +- .../src/index.test.ts | 0 .../{xstate-store => xstate}/src/index.ts | 2 +- .../{xstate-store => xstate}/tsconfig.json | 0 .../{xstate-store => xstate}/tsup.config.ts | 0 pnpm-lock.yaml | 73 ++++++----- skills/json-render-core/SKILL.md | 45 +++++++ skills/json-render-react/SKILL.md | 31 ++++- skills/json-render-shadcn/SKILL.md | 16 ++- 20 files changed, 578 insertions(+), 63 deletions(-) create mode 100644 .changeset/v0-10-release.md rename packages/{xstate-store => xstate}/README.md (84%) rename packages/{xstate-store => xstate}/package.json (93%) rename packages/{xstate-store => xstate}/src/index.test.ts (100%) rename packages/{xstate-store => xstate}/src/index.ts (94%) rename packages/{xstate-store => xstate}/tsconfig.json (100%) rename packages/{xstate-store => xstate}/tsup.config.ts (100%) diff --git a/.changeset/config.json b/.changeset/config.json index 6fb4ed1..9d1f274 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -13,7 +13,9 @@ "@json-render/codegen", "@json-render/zustand", "@json-render/redux", - "@json-render/jotai" + "@json-render/jotai", + "@json-render/vue", + "@json-render/xstate" ] ], "linked": [], diff --git a/.changeset/v0-10-release.md b/.changeset/v0-10-release.md new file mode 100644 index 0000000..4ac3551 --- /dev/null +++ b/.changeset/v0-10-release.md @@ -0,0 +1,74 @@ +--- +"@json-render/core": minor +"@json-render/react": minor +"@json-render/shadcn": minor +"@json-render/vue": minor +"@json-render/xstate": minor +--- + +Dynamic forms, Vue renderer, XState Store adapter, and computed values. + +### New: `@json-render/vue` Package + +Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming. +- `defineRegistry` — create type-safe component registries from catalogs +- `Renderer` — render specs as Vue component trees +- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider` +- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation` +- Streaming: `useUIStream`, `useChatUI` +- External store support via `StateStore` interface + +### New: `@json-render/xstate` Package + +XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend. +- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom +- Requires `@xstate/store` v3+ + +### New: `$computed` Expressions + +Call registered functions from prop expressions: +- `{ "$computed": "functionName", "args": { "key": } }` — calls a named function with resolved args +- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer` +- `ComputedFunction` type exported from `@json-render/core` + +### New: `$template` Expressions + +Interpolate state values into strings: +- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values +- Missing paths resolve to empty string + +### New: State Watchers + +React to state changes by triggering actions: +- `watch` field on elements maps state paths to action bindings +- Fires when watched values change (not on initial render) +- Supports cascading dependencies (e.g. country → city loading) +- `watch` is a top-level field on elements (sibling of type/props/children), not inside props +- Spec validator detects and auto-fixes `watch` placed inside props + +### New: Cross-Field Validation Functions + +New built-in validation functions for cross-field comparisons: +- `equalTo` — alias for `matches` with clearer semantics +- `lessThan` — value must be less than another field (numbers, strings, coerced) +- `greaterThan` — value must be greater than another field +- `requiredIf` — required only when a condition field is truthy +- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling + +### New: `validateForm` Action (React) + +Built-in action that validates all registered form fields at once: +- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state +- Default state path: `/formValidation` (configurable via `statePath` param) +- Added to React schema's built-in actions list + +### Improved: shadcn/ui Validation + +All form components now support validation: +- Checkbox, Radio, Switch — added `checks` and `validateOn` props +- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit) +- Shared validation schemas reduce catalog definition duplication + +### Improved: React Provider Tree + +Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access. diff --git a/README.md b/README.md index 6158a7c..b0a3756 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ function Dashboard({ spec }) { | `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` | | `@json-render/zustand` | Zustand adapter for `StateStore` | | `@json-render/jotai` | Jotai adapter for `StateStore` | -| `@json-render/xstate-store` | XState Store (atom) adapter for `StateStore` | +| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` | ## Renderers @@ -342,10 +342,12 @@ Any prop value can be data-driven using expressions: } ``` -Two expression forms: +Expression forms: - **`{ "$state": "/state/key" }`** - reads a value from the state model -- **`{ "$cond": , "$then": , "$else": }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch +- **`{ "$cond": , "$then": , "$else": }`** - evaluates a condition and picks a branch +- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates state values into strings +- **`{ "$computed": "fn", "args": { ... } }`** - calls a registered function with resolved args ### Actions @@ -361,6 +363,22 @@ Components can trigger actions, including the built-in `setState` action: The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions. +### State Watchers + +React to state changes by triggering actions: + +```json +{ + "type": "Select", + "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] }, + "watch": { + "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } + } +} +``` + +`watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render. + --- ## Demo @@ -376,6 +394,8 @@ pnpm dev - http://dashboard-demo.json-render.localhost:1355 - Example Dashboard - http://remotion-demo.json-render.localhost:1355 - Remotion Video Example - Chat Example: run `pnpm dev` in `examples/chat` +- Vue Example: run `pnpm dev` in `examples/vue` +- Vite Renderers (React + Vue): run `pnpm dev` in `examples/vite-renderers` - React Native example: run `npx expo start` in `examples/react-native` ## How It Works diff --git a/apps/web/app/(main)/docs/api/react/page.mdx b/apps/web/app/(main)/docs/api/react/page.mdx index 3ce8d91..272d79c 100644 --- a/apps/web/app/(main)/docs/api/react/page.mdx +++ b/apps/web/app/(main)/docs/api/react/page.mdx @@ -109,6 +109,27 @@ const { registry } = defineRegistry(catalog, { type Registry = Record>; ``` +### JSONUIProvider + +Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus: + +| Prop | Type | Description | +|------|------|-------------| +| `functions` | `Record` | Named functions for `$computed` expressions in props | + +```tsx + { /* ... */ } }} + functions={{ fullName: (args) => `${args.first} ${args.last}` }} +> + + +``` + +The `functions` prop is also available on `createRenderer`. + ### Component Props (via defineRegistry) ```tsx @@ -237,6 +258,15 @@ const { `ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`. +### useOptionalValidation + +Non-throwing variant of `useValidation()`. Returns `null` when no `ValidationProvider` is present, instead of throwing. Useful in components that may or may not be rendered inside a validation context. + +```typescript +const validation = useOptionalValidation(); +// ValidationContextValue | null +``` + ### useBoundProp Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path. diff --git a/apps/web/app/(main)/docs/changelog/page.mdx b/apps/web/app/(main)/docs/changelog/page.mdx index 78d886a..8eff0ab 100644 --- a/apps/web/app/(main)/docs/changelog/page.mdx +++ b/apps/web/app/(main)/docs/changelog/page.mdx @@ -5,6 +5,119 @@ export const metadata = pageMetadata("docs/changelog") Notable changes and updates to json-render. +## v0.10.0 + +February 2026 + +### New: `@json-render/vue` + +Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support. + +```bash +npm install @json-render/core @json-render/vue +``` + +```typescript +import { h } from "vue"; +import { defineRegistry, Renderer } from "@json-render/vue"; +import { schema } from "@json-render/vue/schema"; + +const { registry } = defineRegistry(catalog, { + components: { + Card: ({ props, children }) => + h("div", { class: "card" }, [h("h3", null, props.title), children]), + Button: ({ props, emit }) => + h("button", { onClick: () => emit("press") }, props.label), + }, +}); +``` + +Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`. + +See the [Vue API reference](/docs/api/vue) for details. + +### New: `@json-render/xstate` + +[XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer. + +```bash +npm install @json-render/xstate @xstate/store +``` + +```typescript +import { createAtom } from "@xstate/store"; +import { xstateStoreStateStore } from "@json-render/xstate"; + +const atom = createAtom({ count: 0 }); +const store = xstateStoreStateStore({ atom }); +``` + +Requires `@xstate/store` v3+. + +### New: `$computed` and `$template` Expressions + +Two new prop expression types for dynamic values: + +- **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }` +- **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }` + +Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details. + +### New: State Watchers + +Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects. + +```json +{ + "type": "Select", + "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, + "watch": { + "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } + } +} +``` + +`watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details. + +### New: Cross-Field Validation + +New built-in validation functions for cross-field comparisons: + +- `equalTo` -- alias for `matches` with clearer semantics +- `lessThan` -- value must be less than another field +- `greaterThan` -- value must be greater than another field +- `requiredIf` -- required only when a condition field is truthy + +Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently. + +### New: `validateForm` Action + +Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state: + +```json +{ + "on": { + "press": [ + { "action": "validateForm", "params": { "statePath": "/formResult" } }, + { "action": "submitForm" } + ] + } +} +``` + +### Improved: shadcn/ui Validation + +All form components now support `checks` and `validateOn` props: +- Checkbox, Radio, Switch added validation support +- `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"` + +### New Examples + +- **Vue example** -- standalone Vue 3 app with custom components +- **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog + +--- + ## v0.9.1 February 2026 diff --git a/apps/web/app/(main)/docs/installation/page.mdx b/apps/web/app/(main)/docs/installation/page.mdx index 21863ca..5106a4f 100644 --- a/apps/web/app/(main)/docs/installation/page.mdx +++ b/apps/web/app/(main)/docs/installation/page.mdx @@ -47,6 +47,8 @@ If you want to wire json-render to an existing state management library instead + + See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage. ## For AI Integration diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index 7852a50..1083566 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -23,7 +23,9 @@ export const PAGE_TITLES: Record = { "docs/streaming": "Streaming", "docs/validation": "Validation", "docs/data-binding": "Data Binding", + "docs/computed-values": "Computed Values", "docs/visibility": "Visibility", + "docs/watchers": "Watchers", "docs/generation-modes": "Generation Modes", "docs/code-export": "Code Export", "docs/custom-schema": "Custom Schema & Renderer", diff --git a/packages/core/README.md b/packages/core/README.md index 8d9123f..2b8ff6e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -188,6 +188,22 @@ Schema options: | `resolvePropValue(value, ctx)` | Resolve a single prop expression | | `resolveElementProps(props, ctx)` | Resolve all prop expressions in an element | | `PropExpression` | Type for prop values that may contain expressions | +| `ComputedFunction` | Function signature for `$computed` expressions | +| `PropResolutionContext` | Context for resolving props (includes `functions` for `$computed`) | + +### Validation + +| Export | Purpose | +|--------|---------| +| `check.required()` | Required validation helper | +| `check.email()` | Email validation helper | +| `check.matches(path)` | Cross-field match helper | +| `check.equalTo(path)` | Cross-field equality helper | +| `check.lessThan(path)` | Cross-field less-than helper | +| `check.greaterThan(path)` | Cross-field greater-than helper | +| `check.requiredIf(path)` | Conditional required helper | +| `builtInValidationFunctions` | All built-in validation functions | +| `runValidationCheck()` | Run a single validation check | ### User Prompt @@ -285,6 +301,7 @@ The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@j | `Spec` | Base spec type | | `Catalog` | Catalog type | | `BuiltInAction` | Built-in action type (`name` + `description`) | +| `ComputedFunction` | Function signature for `$computed` expressions | | `VisibilityCondition` | Visibility condition type (used by `$cond`) | | `VisibilityContext` | Context for evaluating visibility and prop expressions | | `SpecStreamLine` | Single patch operation | @@ -372,6 +389,44 @@ Get the current array index inside a repeat: `$index` uses `true` as a sentinel flag because the index is a scalar value with no sub-path to navigate (unlike `$item` which needs a path). +### Template (`$template`) + +Interpolate state values into strings using `${/path}` syntax: + +```json +{ + "label": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." } +} +``` + +Missing paths resolve to an empty string. + +### Computed (`$computed`) + +Call a registered function with resolved arguments: + +```json +{ + "text": { + "$computed": "fullName", + "args": { + "first": { "$state": "/form/firstName" }, + "last": { "$state": "/form/lastName" } + } + } +} +``` + +Functions are registered in the catalog and provided at runtime via the `functions` prop on the renderer. + +```typescript +import type { ComputedFunction } from "@json-render/core"; + +const functions: Record = { + fullName: (args) => `${args.first} ${args.last}`, +}; +``` + ### API ```typescript @@ -466,6 +521,65 @@ console.log(formatSpecIssues(issues)); const fixed = autoFixSpec(spec); ``` +## State Watchers + +Elements can declare a `watch` field to trigger actions when state values change. `watch` is a top-level field on the element (sibling of `type`, `props`, `children`), not inside `props`. + +```json +{ + "type": "Select", + "props": { + "label": "Country", + "value": { "$bindState": "/form/country" }, + "options": ["US", "Canada", "UK"] + }, + "watch": { + "/form/country": { + "action": "loadCities", + "params": { "country": { "$state": "/form/country" } } + } + }, + "children": [] +} +``` + +Watchers only fire on value changes, not on initial render. Multiple action bindings per path execute sequentially. + +## Validation + +### Built-in Validation Functions + +| Function | Description | Args | +|----------|-------------|------| +| `required` | Value must not be empty | — | +| `email` | Must be a valid email | — | +| `url` | Must be a valid URL | — | +| `numeric` | Must be a number | — | +| `minLength` | Minimum string length | `{ min: number }` | +| `maxLength` | Maximum string length | `{ max: number }` | +| `min` | Minimum numeric value | `{ min: number }` | +| `max` | Maximum numeric value | `{ max: number }` | +| `pattern` | Must match regex | `{ pattern: string }` | +| `matches` | Must equal another field | `{ other: { $state: "/path" } }` | +| `equalTo` | Alias for matches | `{ other: { $state: "/path" } }` | +| `lessThan` | Must be less than another field | `{ other: { $state: "/path" } }` | +| `greaterThan` | Must be greater than another field | `{ other: { $state: "/path" } }` | +| `requiredIf` | Required when condition is truthy | `{ field: { $state: "/path" } }` | + +### TypeScript Helpers + +```typescript +import { check } from "@json-render/core"; + +check.required("Field is required"); +check.email("Invalid email"); +check.matches("/form/password", "Passwords must match"); +check.equalTo("/form/password", "Passwords must match"); +check.lessThan("/form/endDate", "Must be before end date"); +check.greaterThan("/form/startDate", "Must be after start date"); +check.requiredIf("/form/enableNotifications", "Required when notifications enabled"); +``` + ## Custom Schemas json-render supports completely different spec formats for different renderers: diff --git a/packages/react/README.md b/packages/react/README.md index 48af67d..9d2b628 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -261,6 +261,7 @@ const { errors, validate } = useFieldValidation("/form/email", { | `useActions()` | Access action context | | `useAction(name)` | Get a single action dispatch function | | `useFieldValidation(path, config)` | Field validation state | +| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) | | `useUIStream(options)` | Stream specs from an API endpoint | ## Visibility Conditions @@ -324,11 +325,60 @@ Any prop value can use data-driven expressions that resolve at render time. The For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`. +### `$template` and `$computed` + +```json +{ + "label": { "$template": "Hello, ${/user/name}!" }, + "fullName": { + "$computed": "fullName", + "args": { + "first": { "$state": "/form/firstName" }, + "last": { "$state": "/form/lastName" } + } + } +} +``` + +Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`: + +```tsx + `${args.first} ${args.last}` }} +> +``` + See [@json-render/core](../core/README.md) for full expression syntax. +## State Watchers + +Elements can declare a `watch` field to trigger actions when state values change: + +```json +{ + "type": "Select", + "props": { + "label": "Country", + "value": { "$bindState": "/form/country" }, + "options": ["US", "Canada", "UK"] + }, + "watch": { + "/form/country": { + "action": "loadCities", + "params": { "country": { "$state": "/form/country" } } + } + }, + "children": [] +} +``` + +`watch` is a top-level field on elements (sibling of `type`/`props`/`children`), not inside `props`. Watchers only fire on value changes, not on initial render. + ## Built-in Actions -The `setState`, `pushState`, and `removeState` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions: +The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`: ```json { @@ -344,6 +394,26 @@ The `setState`, `pushState`, and `removeState` actions are built into the React } ``` +### `validateForm` + +Validate all registered form fields at once and write the result to state: + +```json +{ + "type": "Button", + "props": { "label": "Submit" }, + "on": { + "press": [ + { "action": "validateForm", "params": { "statePath": "/formResult" } }, + { "action": "submitForm" } + ] + }, + "children": [] +} +``` + +Writes `{ valid: boolean, errors: Record }` to the specified state path (defaults to `/formValidation`). + ## Component Props When using `defineRegistry`, components receive these props: @@ -451,7 +521,7 @@ function App() { |--------|---------| | `defineRegistry` | Create a type-safe component registry from a catalog | | `Renderer` | Render a spec using a registry | -| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) | +| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) | | `useStateStore` | Access state context | | `useStateValue` | Get single value from state | | `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | diff --git a/packages/shadcn/README.md b/packages/shadcn/README.md index 118537d..8938aac 100644 --- a/packages/shadcn/README.md +++ b/packages/shadcn/README.md @@ -167,12 +167,12 @@ const { registry } = defineRegistry(catalog, { |-----------|-------------| | `Button` | Clickable button with variants | | `Link` | Anchor link | -| `Input` | Text input with label and validation | -| `Textarea` | Multi-line text input | -| `Select` | Dropdown select | -| `Checkbox` | Checkbox input | -| `Radio` | Radio button group | -| `Switch` | Toggle switch | +| `Input` | Text input with label, validation, and `validateOn` timing | +| `Textarea` | Multi-line text input with validation and `validateOn` | +| `Select` | Dropdown select with validation and `validateOn` | +| `Checkbox` | Checkbox input with validation and `validateOn` | +| `Radio` | Radio button group with validation and `validateOn` | +| `Switch` | Toggle switch with validation and `validateOn` | | `Slider` | Range slider | | `Toggle` | Toggle button | | `ToggleGroup` | Group of toggle buttons | @@ -180,13 +180,24 @@ const { registry } = defineRegistry(catalog, { ## Built-in Actions -State actions (`setState`, `pushState`, `removeState`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog. +State actions (`setState`, `pushState`, `removeState`, `validateForm`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog. | Action | Description | |--------|-------------| | `setState` | Set a value at a state path | | `pushState` | Push a value onto an array in state | | `removeState` | Remove an item from an array in state | +| `validateForm` | Validate all fields and write result to state | + +### Validation Timing (`validateOn`) + +All form components support the `validateOn` prop to control when validation runs: + +| Value | Description | Default For | +|-------|-------------|-------------| +| `"change"` | Validate on every input change | Select, Checkbox, Radio, Switch | +| `"blur"` | Validate when field loses focus | Input, Textarea | +| `"submit"` | Validate only on form submission | — | ## Exports diff --git a/packages/xstate-store/README.md b/packages/xstate/README.md similarity index 84% rename from packages/xstate-store/README.md rename to packages/xstate/README.md index 95deb1e..57fb404 100644 --- a/packages/xstate-store/README.md +++ b/packages/xstate/README.md @@ -1,11 +1,11 @@ -# @json-render/xstate-store +# @json-render/xstate [XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for json-render. ## Installation ```bash -npm install @json-render/xstate-store @json-render/core @json-render/react @xstate/store +npm install @json-render/xstate @json-render/core @json-render/react @xstate/store ``` > [!NOTE] @@ -15,7 +15,7 @@ npm install @json-render/xstate-store @json-render/core @json-render/react @xsta ```ts import { createAtom } from "@xstate/store"; -import { xstateStoreStateStore } from "@json-render/xstate-store"; +import { xstateStoreStateStore } from "@json-render/xstate"; import { StateProvider } from "@json-render/react"; // 1. Create an atom diff --git a/packages/xstate-store/package.json b/packages/xstate/package.json similarity index 93% rename from packages/xstate-store/package.json rename to packages/xstate/package.json index c9c43f1..46aa95b 100644 --- a/packages/xstate-store/package.json +++ b/packages/xstate/package.json @@ -1,5 +1,5 @@ { - "name": "@json-render/xstate-store", + "name": "@json-render/xstate", "version": "0.0.0", "license": "Apache-2.0", "description": "XState Store adapter for json-render StateStore", @@ -13,7 +13,7 @@ "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", - "directory": "packages/xstate-store" + "directory": "packages/xstate" }, "homepage": "https://github.com/vercel-labs/json-render#readme", "bugs": { diff --git a/packages/xstate-store/src/index.test.ts b/packages/xstate/src/index.test.ts similarity index 100% rename from packages/xstate-store/src/index.test.ts rename to packages/xstate/src/index.test.ts diff --git a/packages/xstate-store/src/index.ts b/packages/xstate/src/index.ts similarity index 94% rename from packages/xstate-store/src/index.ts rename to packages/xstate/src/index.ts index 4ac10a0..30ad38f 100644 --- a/packages/xstate-store/src/index.ts +++ b/packages/xstate/src/index.ts @@ -18,7 +18,7 @@ export interface XstateStoreStateStoreOptions { * @example * ```ts * import { createAtom } from "@xstate/store"; - * import { xstateStoreStateStore } from "@json-render/xstate-store"; + * import { xstateStoreStateStore } from "@json-render/xstate"; * * const uiAtom = createAtom>({ count: 0 }); * diff --git a/packages/xstate-store/tsconfig.json b/packages/xstate/tsconfig.json similarity index 100% rename from packages/xstate-store/tsconfig.json rename to packages/xstate/tsconfig.json diff --git a/packages/xstate-store/tsup.config.ts b/packages/xstate/tsup.config.ts similarity index 100% rename from packages/xstate-store/tsup.config.ts rename to packages/xstate/tsup.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d886ae..e3388a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,7 +448,7 @@ importers: version: 0.563.0(react@19.2.4) next: specifier: 16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -722,7 +722,7 @@ importers: version: 6.0.91(zod@4.3.6) next: specifier: ^16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.1.0 version: 19.2.4 @@ -1232,7 +1232,7 @@ importers: specifier: ^3.5.0 version: 3.5.29(typescript@5.9.2) - packages/xstate-store: + packages/xstate: dependencies: '@json-render/core': specifier: workspace:* @@ -1246,10 +1246,10 @@ importers: version: 3.15.0(react@19.2.4) tsup: specifier: ^8.0.2 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.4.5 - version: 5.9.2 + version: 5.9.3 packages/zustand: dependencies: @@ -16574,7 +16574,7 @@ snapshots: '@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))': dependencies: '@types/jest': 28.1.8 - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5) + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5) '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5) eslint: 8.57.1 eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -16929,7 +16929,7 @@ snapshots: '@types/node': 22.19.6 optional: true - '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)': + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5) @@ -22106,32 +22106,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@next/env': 16.1.6 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.14 - caniuse-lite: 1.0.30001764 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 - '@opentelemetry/api': 1.9.0 - babel-plugin-react-compiler: 1.0.0 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - node-abi@3.87.0: dependencies: semver: 7.7.3 @@ -24152,11 +24126,6 @@ snapshots: client-only: 0.0.1 react: 19.2.3 - styled-jsx@5.1.6(react@19.2.4): - dependencies: - client-only: 0.0.1 - react: 19.2.4 - sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -24464,6 +24433,34 @@ snapshots: - tsx - yaml + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.2 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + resolve-from: 5.0.0 + rollup: 4.55.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsutils@3.21.0(typescript@4.9.5): dependencies: tslib: 1.14.1 diff --git a/skills/json-render-core/SKILL.md b/skills/json-render-core/SKILL.md index 8b7c73f..9cbd762 100644 --- a/skills/json-render-core/SKILL.md +++ b/skills/json-render-core/SKILL.md @@ -85,6 +85,8 @@ Any prop value can be a dynamic expression resolved at render time: - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components. - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes. - **`{ "$cond": , "$then": , "$else": }`** - evaluates a visibility condition and picks a branch +- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates `${/path}` references with state values +- **`{ "$computed": "fnName", "args": { "key": } }`** - calls a registered function with resolved args `$cond` uses the same syntax as visibility conditions (`$state`, `eq`, `neq`, `not`, arrays for AND). `$then` and `$else` can themselves be expressions (recursive). @@ -96,6 +98,14 @@ Components do not use a `statePath` prop for two-way binding. Instead, use `{ "$ "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" + }, + "label": { "$template": "Welcome, ${/user/name}!" }, + "fullName": { + "$computed": "fullName", + "args": { + "first": { "$state": "/form/firstName" }, + "last": { "$state": "/form/lastName" } + } } } ``` @@ -106,6 +116,39 @@ import { resolvePropValue, resolveElementProps } from "@json-render/core"; const resolved = resolveElementProps(element.props, { stateModel: myState }); ``` +## State Watchers + +Elements can declare a `watch` field (top-level, sibling of type/props/children) to trigger actions when state values change: + +```json +{ + "type": "Select", + "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, + "watch": { + "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } + }, + "children": [] +} +``` + +Watchers only fire on value changes, not on initial render. + +## Validation + +Built-in validation functions: `required`, `email`, `url`, `numeric`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `matches`, `equalTo`, `lessThan`, `greaterThan`, `requiredIf`. + +Cross-field validation uses `$state` expressions in args: + +```typescript +import { check } from "@json-render/core"; + +check.required("Field is required"); +check.matches("/form/password", "Passwords must match"); +check.lessThan("/form/endDate", "Must be before end date"); +check.greaterThan("/form/startDate", "Must be after start date"); +check.requiredIf("/form/enableNotifications", "Required when enabled"); +``` + ## User Prompt Builder Build structured user prompts with optional spec refinement and state context: @@ -208,5 +251,7 @@ The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`, | `parseSpecStreamLine` | Parse single JSONL line | | `applySpecStreamPatch` | Apply patch to object | | `StateStore` | Interface for plugging in external state management | +| `ComputedFunction` | Function signature for `$computed` expressions | +| `check` | TypeScript helpers for creating validation checks | | `BuiltInAction` | Type for built-in action definitions (`name` + `description`) | | `ActionBinding` | Action binding type (includes `preventDefault` field) | diff --git a/skills/json-render-react/SKILL.md b/skills/json-render-react/SKILL.md index 6e824bb..0f8242b 100644 --- a/skills/json-render-react/SKILL.md +++ b/skills/json-render-react/SKILL.md @@ -119,6 +119,8 @@ Any prop value can be a data-driven expression resolved by the renderer before c - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components. - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes. - **`{ "$cond": , "$then": , "$else": }`** - conditional value +- **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings +- **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args ```json { @@ -134,6 +136,14 @@ Components do not use a `statePath` prop for two-way binding. Use `{ "$bindState Components receive already-resolved props. For two-way bound props, use the `useBoundProp` hook with the `bindings` map the renderer provides. +Register `$computed` functions via the `functions` prop on `JSONUIProvider` or `createRenderer`: + +```tsx + `${args.first} ${args.last}` }} +> +``` + ## Event System Components use `emit` to fire named events, or `on()` to get an event handle with metadata. The element's `on` field maps events to action bindings: @@ -166,16 +176,32 @@ Link: ({ props, on }) => { The `EventHandle` returned by `on()` has: `emit()`, `shouldPreventDefault` (boolean), and `bound` (boolean). +## State Watchers + +Elements can declare a `watch` field (top-level, sibling of type/props/children) to trigger actions when state values change: + +```json +{ + "type": "Select", + "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, + "watch": { "/form/country": { "action": "loadCities" } }, + "children": [] +} +``` + ## Built-in Actions -The `setState`, `pushState`, and `removeState` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in catalog `actions`: +The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in catalog `actions`: ```json { "action": "setState", "params": { "statePath": "/activeTab", "value": "home" } } { "action": "pushState", "params": { "statePath": "/items", "value": { "text": "New" } } } { "action": "removeState", "params": { "statePath": "/items", "index": 0 } } +{ "action": "validateForm", "params": { "statePath": "/formResult" } } ``` +`validateForm` validates all registered fields and writes `{ valid, errors }` to state. + Note: `statePath` in action params (e.g. `setState.statePath`) targets the mutation path. Two-way binding in component props uses `{ "$bindState": "/path" }` on the value prop, not `statePath`. ## useBoundProp @@ -223,12 +249,13 @@ const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => ( |--------|---------| | `defineRegistry` | Create a type-safe component registry from a catalog | | `Renderer` | Render a spec using a registry | -| `schema` | Element tree schema (includes built-in state actions) | +| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) | | `useStateStore` | Access state context | | `useStateValue` | Get single value from state | | `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | | `useActions` | Access actions context | | `useAction` | Get a single action dispatch function | +| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) | | `useUIStream` | Stream specs from an API endpoint | | `createStateStore` | Create a framework-agnostic in-memory `StateStore` | | `StateStore` | Interface for plugging in external state management | diff --git a/skills/json-render-shadcn/SKILL.md b/skills/json-render-shadcn/SKILL.md index d6c8a86..e0ce21d 100644 --- a/skills/json-render-shadcn/SKILL.md +++ b/skills/json-render-shadcn/SKILL.md @@ -126,9 +126,9 @@ const { registry } = defineRegistry(catalog, { - **Input** - Text input with label, name, type, placeholder, value, checks - **Textarea** - Multi-line input with label, name, placeholder, rows, value, checks - **Select** - Dropdown select with label, name, options (string[]), value, checks -- **Checkbox** - Checkbox with label, name, checked -- **Radio** - Radio group with label, name, options (string[]), value -- **Switch** - Toggle switch with label, name, checked +- **Checkbox** - Checkbox with label, name, checked, checks, validateOn +- **Radio** - Radio group with label, name, options (string[]), value, checks, validateOn +- **Switch** - Toggle switch with label, name, checked, checks, validateOn - **Slider** - Range slider with label, min, max, step, value - **Toggle** - Toggle button with label, pressed, variant - **ToggleGroup** - Group of toggles with items, type, value @@ -141,11 +141,19 @@ These are built into the React schema and handled by `ActionProvider` automatica - **setState** - Set a value at a state path (`{ statePath, value }`) - **pushState** - Push a value onto an array (`{ statePath, value, clearStatePath? }`) - **removeState** - Remove an array item by index (`{ statePath, index }`) +- **validateForm** - Validate all fields, write `{ valid, errors }` to state (`{ statePath? }`) + +## Validation Timing (`validateOn`) + +All form components support `validateOn` to control when validation runs: +- `"change"` — validate on every input change (default for Select, Checkbox, Radio, Switch) +- `"blur"` — validate when field loses focus (default for Input, Textarea) +- `"submit"` — validate only on form submission ## Important Notes - The `/catalog` entry point has no React dependency -- use it for server-side prompt generation - Components use Tailwind CSS classes -- your app must have Tailwind configured - Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`) -- Form inputs support `checks` for validation (type + message pairs) +- All form inputs support `checks` for validation (type + message pairs) and `validateOn` for timing - Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`