Compare commits

..
Author SHA1 Message Date
Chris Tate 3c11f19be4 vue improvements (#163)
* vue improvements

* fixes

* fixes

* fixes

* fix ci

* fix ci
2026-02-25 09:44:57 -06:00
Anthony Fu db3a8b41e9 feat: add Vue renderer (#162)
* feat: vue support

* feat: add a vite example

* use css instead of inline styles

* feat: add tests

* chore: use portless

* fix: update reactivity

* chore: update

* chore: build

* feat: add hooks, add missing zod peer deps
2026-02-25 08:21:11 -06:00
Chris Tate ea47b66dfc add dynamic forms support: computed values, watchers, cross-field validation (#156)
* add dynamic forms support: computed values, watchers, cross-field validation

- `$computed` and `$template` prop expressions for derived values and string interpolation
- Element-level `watch` field for cascading state dependencies
- Cross-field validators (`lessThan`, `greaterThan`, `equalTo`, `requiredIf`) with deep arg resolution
- `validateForm` built-in action for form-level validation

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tests

* fixes

* update turbo

* tests

* fixes

* fixes

* fixes
2026-02-24 23:04:48 -06:00
Chris Tate cd82f969c8 xstate doc/test updates (#160) 2026-02-24 22:09:42 -06:00
David KhourshidandClaude Opus 4.6 6bcaaad57d feat: Add @xstate/store (atom) support (#157)
* feat: add @xstate/store integration using atoms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add README for @json-render/xstate-store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rename xstateStoreStateStore to xstateStore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* README updates

* Keep naming convention

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:01:33 -06:00
88 changed files with 11420 additions and 219 deletions
+28 -3
View File
@@ -5,15 +5,18 @@
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output. Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
```bash ```bash
# for React
npm install @json-render/core @json-render/react npm install @json-render/core @json-render/react
# pre-built shadcn/ui components # for React with pre-built shadcn/ui components
npm install @json-render/shadcn npm install @json-render/shadcn
# or for mobile # or for React Native
npm install @json-render/core @json-render/react-native npm install @json-render/core @json-render/react-native
# or for video # or for video
npm install @json-render/core @json-render/remotion npm install @json-render/core @json-render/remotion
# or for PDF documents # or for PDF documents
npm install @json-render/core @json-render/react-pdf npm install @json-render/core @json-render/react-pdf
# or for Vue
npm install @json-render/core @json-render/vue
``` ```
## Why json-render? ## Why json-render?
@@ -23,7 +26,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **Guardrailed** - AI can only use components in your catalog - **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time - **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds - **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog - **Cross-Platform** - React, Vue (web), React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use - **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start ## Quick Start
@@ -110,6 +113,7 @@ function Dashboard({ spec }) {
|---------|-------------| |---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities | | `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks | | `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) | | `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components | | `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema | | `@json-render/remotion` | Remotion video renderer, timeline schema |
@@ -117,6 +121,7 @@ function Dashboard({ spec }) {
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` | | `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` | | `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` | | `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate-store` | XState Store (atom) adapter for `StateStore` |
## Renderers ## Renderers
@@ -148,6 +153,26 @@ const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} /> <Renderer spec={spec} registry={registry} />
``` ```
### Vue (UI)
```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),
},
});
// In your Vue component template:
// <Renderer :spec="spec" :registry="registry" />
```
### shadcn/ui (Web) ### shadcn/ui (Web)
```tsx ```tsx
+246
View File
@@ -0,0 +1,246 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/vue")
# @json-render/vue
Vue 3 components, providers, and composables.
## Providers
### StateProvider
```vue
<StateProvider :initial-state="object" :on-state-change="fn">
<!-- children -->
</StateProvider>
```
| Prop | Type | Description |
|------|------|-------------|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
```typescript
import { createStateStore, type StateStore } from "@json-render/vue";
const store = createStateStore({ count: 0 });
```
```vue
<StateProvider :store="store">
<!-- children -->
</StateProvider>
```
```typescript
// Mutate from anywhere — Vue re-renders automatically:
store.set("/count", 1);
```
### ActionProvider
```vue
<ActionProvider :handlers="Record<string, ActionHandler>" :navigate="fn">
<!-- children -->
</ActionProvider>
// type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
```
### VisibilityProvider
```vue
<VisibilityProvider>
<!-- children -->
</VisibilityProvider>
```
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
### ValidationProvider
```vue
<ValidationProvider :custom-functions="Record<string, ValidationFunction>">
<!-- children -->
</ValidationProvider>
// type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
```
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
```typescript
import { h } from "vue";
import { defineRegistry } from "@json-render/vue";
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),
},
// Required when catalog declares actions:
actions: {
submit: async (params) => { /* ... */ },
},
});
// Pass to <Renderer>
// <Renderer :spec="spec" :registry="registry" />
```
## Components
### Renderer
```vue
<Renderer
:spec="Spec" // The UI spec to render
:registry="Registry" // Component registry (from defineRegistry)
:loading="boolean" // Optional loading state
:fallback="Component" // Optional fallback for unknown types
/>
```
### Component Props (via defineRegistry)
```typescript
import type { VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: VNode | VNode[]; // Rendered children (for container components)
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean;
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
```typescript
Link: ({ props, on }) => {
const click = on("click");
return h("a", {
href: props.href,
onClick: (e: MouseEvent) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
},
}, props.label);
},
```
### BaseComponentProps
Catalog-agnostic base type for building reusable component libraries that are not tied to a specific catalog:
```typescript
import type { BaseComponentProps } from "@json-render/vue";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
h("div", null, [props.title, children]);
```
## Composables
### useStateStore
```typescript
const {
state, // ShallowRef<StateModel> — access with state.value
get, // (path: string) => unknown
set, // (path: string, value: unknown) => void
update, // (updates: Record<string, unknown>) => void
} = useStateStore();
```
> **Note:** `state` is a `ShallowRef<StateModel>`, not a plain object. Use `state.value` to read the current state. This differs from the React renderer.
### useStateValue
```typescript
const value = useStateValue(path: string); // ComputedRef<T | undefined>
```
Returns a `ComputedRef` that automatically updates when the state at `path` changes. Use `.value` to access the current value.
### useStateBinding (deprecated)
> **Deprecated.** Use `$bindState` expressions with `bindings` prop instead.
```typescript
const [value, setValue] = useStateBinding(path: string);
// value: ComputedRef<T | undefined>
// setValue: (value: T) => void
```
### useActions
```typescript
const { execute } = useActions();
// execute(binding: ActionBinding) => Promise<void>
```
### useAction
```typescript
const { execute, isLoading } = useAction(binding: ActionBinding);
// execute: () => Promise<void>
// isLoading: ComputedRef<boolean>
```
### useIsVisible
```typescript
const isVisible = useIsVisible(condition?: VisibilityCondition);
```
### useFieldValidation
```typescript
const {
state, // ComputedRef<FieldValidationState>
validate, // () => ValidationResult
touch, // () => void
clear, // () => void
errors, // ComputedRef<string[]>
isValid, // ComputedRef<boolean>
} = useFieldValidation(path: string, config?: ValidationConfig);
```
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
## Differences from `@json-render/react`
| API | React | Vue | Note |
|-----|-------|-----|------|
| `useStateStore().state` | `StateModel` (plain object) | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
| `useStateValue()` | `T \| undefined` | `ComputedRef<T \| undefined>` | Vue reactivity; use `.value` |
| `useStateBinding()` | `[T \| undefined, setter]` | `[ComputedRef<T \| undefined>, setter]` | Vue reactivity; use `value.value` |
| `useAction().isLoading` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `useFieldValidation().state` | `FieldValidationState` | `ComputedRef<FieldValidationState>` | Vue reactivity; use `.value` |
| `useFieldValidation().errors` | `string[]` | `ComputedRef<string[]>` | Vue reactivity; use `.value` |
| `useFieldValidation().isValid` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `VisibilityContextValue.ctx` | `CoreVisibilityContext` | `ComputedRef<CoreVisibilityContext>` | Vue reactivity; use `ctx.value` |
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
| `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
@@ -0,0 +1,119 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/computed-values")
# Computed Values
Derive dynamic prop values using registered functions or string templates.
## `$template` — String Interpolation
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
},
"children": []
}
```
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
Missing paths resolve to an empty string.
## `$computed` — Registered Functions
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
```json
{
"type": "Text",
"props": {
"text": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
},
"children": []
}
```
### Registering Functions
Functions are registered in the catalog and provided at runtime.
**Catalog definition (for AI prompt generation):**
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
functions: {
fullName: {
description: 'Combines first and last name into a full name',
},
formatCurrency: {
description: 'Formats a number as currency',
},
},
});
```
**Runtime implementation:**
```tsx
import { JSONUIProvider } from '@json-render/react';
const functions = {
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
formatCurrency: (args) => {
const value = Number(args.value ?? 0);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: (args.currency as string) ?? 'USD',
}).format(value);
},
};
<JSONUIProvider registry={registry} functions={functions}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
### Using with `createRenderer`
```tsx
const MyRenderer = createRenderer(catalog, components);
<MyRenderer
spec={spec}
functions={functions}
/>
```
## Combining Expressions
`$computed` args can use any expression type. This example computes a total from repeat item fields:
```json
{
"$computed": "lineTotal",
"args": {
"price": { "$item": "price" },
"quantity": { "$item": "quantity" }
}
}
```
## Next
- [Watchers](/docs/watchers) — react to state changes with cascading actions
- [Data Binding](/docs/data-binding) — all expression types
- [Validation](/docs/validation) — validate form inputs
@@ -213,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
The condition uses the same [visibility](/docs/visibility) expression format. The condition uses the same [visibility](/docs/visibility) expression format.
## Template Strings
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Welcome back, ${/user/name}!" }
},
"children": []
}
```
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
## Quick Reference ## Quick Reference
<div className="my-6 overflow-x-auto"> <div className="my-6 overflow-x-auto">
@@ -255,6 +271,16 @@ The condition uses the same [visibility](/docs/visibility) expression format.
<td><code>{'{ "$bindItem": "field" }'}</code></td> <td><code>{'{ "$bindItem": "field" }'}</code></td>
<td>Form components inside repeat</td> <td>Form components inside repeat</td>
</tr> </tr>
<tr>
<td><code>{"$template"}</code></td>
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
<td>Anywhere (string props)</td>
</tr>
<tr>
<td><code>{"$computed"}</code></td>
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
<td>Anywhere (requires registered function)</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
+12 -9
View File
@@ -9,6 +9,18 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/react" /> <PackageInstall packages="@json-render/core @json-render/react" />
Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`.
<PackageInstall packages="react zod" />
## For Vue
<PackageInstall packages="@json-render/core @json-render/vue" />
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
<PackageInstall packages="vue zod" />
## For React UI with shadcn/ui ## For React UI with shadcn/ui
Pre-built components for fast prototyping and production use: Pre-built components for fast prototyping and production use:
@@ -37,15 +49,6 @@ 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. See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
## Peer Dependencies
json-render requires the following peer dependencies:
- `react` ^19.0.0
- `zod` ^4.0.0
<PackageInstall packages="react zod" />
## For AI Integration ## For AI Integration
To use json-render with AI models, you'll also need the Vercel AI SDK: To use json-render with AI models, you'll also need the Vercel AI SDK:
+114 -7
View File
@@ -11,11 +11,18 @@ json-render includes common validation functions:
- `required` — Value must be non-empty - `required` — Value must be non-empty
- `email` — Valid email format - `email` — Valid email format
- `minLength` — Minimum string length - `minLength` — Minimum string length (args: `{ "min": N }`)
- `maxLength` — Maximum string length - `maxLength` — Maximum string length (args: `{ "max": N }`)
- `pattern` — Match a regex pattern - `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
- `min` — Minimum numeric value - `min` — Minimum numeric value (args: `{ "min": N }`)
- `max` — Maximum numeric value - `max` — Maximum numeric value (args: `{ "max": N }`)
- `numeric` — Value must be a number
- `url` — Valid URL format
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
## Using Validation in JSON ## Using Validation in JSON
@@ -143,14 +150,114 @@ function TextField({ props, bindings }) {
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation. See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
## Cross-Field Validation
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
```json
{
"type": "Input",
"props": {
"label": "Confirm Password",
"value": { "$bindState": "/form/confirmPassword" },
"checks": [
{ "type": "required", "message": "Please confirm your password" },
{
"type": "matches",
"args": { "other": { "$state": "/form/password" } },
"message": "Passwords must match"
}
]
}
}
```
Other cross-field examples:
```json
{
"checks": [
{
"type": "greaterThan",
"args": { "other": { "$state": "/form/startDate" } },
"message": "End date must be after start date"
}
]
}
```
```json
{
"checks": [
{
"type": "requiredIf",
"args": { "field": { "$state": "/form/enableNotifications" } },
"message": "Email is required when notifications are enabled"
}
]
}
```
## Conditional Validation
Use the `enabled` field in the validation config to only run checks when a condition is met:
```json
{
"type": "Input",
"props": {
"label": "Company Name",
"value": { "$bindState": "/form/company" },
"checks": [
{ "type": "required", "message": "Company name is required" }
]
}
}
```
In the component implementation, you can pass `enabled` to `useFieldValidation`:
```typescript
useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
enabled: { "$state": "/form/accountType", eq: "business" },
});
```
This only validates the company name when the account type is "business".
## Validation Timing ## Validation Timing
Control when validation runs with `validateOn`: Control when validation runs with `validateOn`:
- `change` — Validate on every input change - `change` — Validate on every input change
- `blur` — Validate when field loses focus - `blur` — Validate when field loses focus (default for Input, Textarea)
- `submit` — Validate only on form submission - `submit` — Validate only on form submission
## Form-Level Validation
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
```json
{
"type": "Button",
"props": { "label": "Submit" },
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
},
"children": []
}
```
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
## Next ## Next
Learn about [generation modes](/docs/generation-modes). - [Computed Values](/docs/computed-values) — derive dynamic prop values
- [Watchers](/docs/watchers) — react to state changes
- [Generation Modes](/docs/generation-modes) — how AI generates specs
+150
View File
@@ -0,0 +1,150 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/watchers")
# Watchers
React to state changes by triggering actions when watched paths update.
## The `watch` Field
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
`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": []
}
```
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
## Cascading Selects
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
```json
{
"root": "form",
"elements": {
"form": {
"type": "Stack",
"props": { "direction": "vertical", "gap": "md" },
"children": ["country-select", "city-select"]
},
"country-select": {
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": [
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
]
},
"children": []
},
"city-select": {
"type": "Select",
"props": {
"label": "City",
"value": { "$bindState": "/form/city" },
"options": { "$state": "/availableCities" },
"placeholder": "Select a city"
},
"children": []
}
},
"state": {
"form": { "country": "", "city": "" },
"availableCities": []
}
}
```
The watcher on `country-select` fires two actions when the country changes:
1. `loadCities` — fetches and writes city options to `/availableCities`
2. `setState` — resets the city selection
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
### Action Handler
```typescript
const handlers = {
loadCities: async (params) => {
const cities = await fetchCities(params.country);
// setState is called by the runtime to write the result
return cities;
},
};
```
Or with `defineRegistry`:
```typescript
const { registry, handlers } = defineRegistry(catalog, {
components: { /* ... */ },
actions: {
loadCities: async (params, setState) => {
const response = await fetch(`/api/cities?country=${params.country}`);
const cities = await response.json();
setState('/availableCities', cities);
},
},
});
```
## Multiple Watchers
An element can watch multiple state paths. Each path maps to one or more action bindings:
```json
{
"watch": {
"/form/startDate": { "action": "validateDateRange" },
"/form/endDate": { "action": "validateDateRange" },
"/form/quantity": [
{ "action": "recalculateTotal" },
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
]
}
}
```
## Behavior
- Watchers only fire on **value changes**, not on the initial render
- Comparison is by reference (`===`), not deep equality
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
- Multiple action bindings on the same path execute sequentially
## When to Use `watch` vs `on`
| Mechanism | Trigger | Use Case |
|-----------|---------|----------|
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
## Next
- [Data Binding](/docs/data-binding) — connect elements to state
- [Computed Values](/docs/computed-values) — derive prop values
- [Visibility](/docs/visibility) — conditionally show or hide elements
+13
View File
@@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [
{ title: "Schemas", href: "/docs/schemas" }, { title: "Schemas", href: "/docs/schemas" },
{ title: "Catalog", href: "/docs/catalog" }, { title: "Catalog", href: "/docs/catalog" },
{ title: "Data Binding", href: "/docs/data-binding" }, { title: "Data Binding", href: "/docs/data-binding" },
{ title: "Computed Values", href: "/docs/computed-values" },
{ title: "Visibility", href: "/docs/visibility" }, { title: "Visibility", href: "/docs/visibility" },
{ title: "Watchers", href: "/docs/watchers" },
{ title: "Validation", href: "/docs/validation" }, { title: "Validation", href: "/docs/validation" },
], ],
}, },
@@ -67,6 +69,16 @@ export const docsNavigation: NavSection[] = [
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion", href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true, external: true,
}, },
{
title: "Vue",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
external: true,
},
{
title: "Renders with Vite (Vue / React)",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
external: true,
},
], ],
}, },
{ {
@@ -95,6 +107,7 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" }, { title: "@json-render/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" }, { title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" }, { title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" }, { title: "@json-render/codegen", href: "/docs/api/codegen" },
], ],
}, },
+1
View File
@@ -38,6 +38,7 @@ export const PAGE_TITLES: Record<string, string> = {
// API references // API references
"docs/api/core": "@json-render/core API", "docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API", "docs/api/react": "@json-render/react API",
"docs/api/vue": "@json-render/vue API",
"docs/api/react-pdf": "@json-render/react-pdf API", "docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-native": "@json-render/react-native API", "docs/api/react-native": "@json-render/react-native API",
"docs/api/codegen": "@json-render/codegen API", "docs/api/codegen": "@json-render/codegen API",
+59 -41
View File
@@ -2,33 +2,31 @@
import { useState, useEffect, useCallback, type ReactNode } from "react"; import { useState, useEffect, useCallback, type ReactNode } from "react";
import ConfettiExplosion from "react-confetti-explosion"; import ConfettiExplosion from "react-confetti-explosion";
import { import { JSONUIProvider, Renderer } from "@json-render/react";
Renderer,
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
} from "@json-render/react";
import type { Spec } from "@json-render/core"; import type { Spec } from "@json-render/core";
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry"; import {
registry,
actionHandlers,
computedFunctions,
onConfetti,
} from "@/lib/render/registry";
import { examples } from "@/lib/examples"; import { examples } from "@/lib/examples";
function SpecRenderer({ spec }: { spec: Spec }): ReactNode { function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
return ( return (
<StateProvider initialState={spec.state ?? {}}> <JSONUIProvider
<VisibilityProvider> registry={registry}
<ActionProvider handlers={actionHandlers}> initialState={spec.state ?? {}}
<ValidationProvider> handlers={actionHandlers}
<Renderer spec={spec} registry={registry} /> functions={computedFunctions}
</ValidationProvider> >
</ActionProvider> <Renderer spec={spec} registry={registry} />
</VisibilityProvider> </JSONUIProvider>
</StateProvider>
); );
} }
export default function Page() { export default function Page() {
const [selectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const selected = examples[selectedIndex]!; const selected = examples[selectedIndex]!;
const [confettiKey, setConfettiKey] = useState(0); const [confettiKey, setConfettiKey] = useState(0);
const [confettiActive, setConfettiActive] = useState(false); const [confettiActive, setConfettiActive] = useState(false);
@@ -41,30 +39,50 @@ export default function Page() {
useEffect(() => onConfetti(fireConfetti), [fireConfetti]); useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
return ( return (
<div className="h-screen flex items-center justify-center bg-muted/30"> <div className="h-screen flex flex-col bg-muted/30">
<div {/* Example selector */}
className="relative bg-background border rounded-lg shadow-sm" <nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
style={{ width: 960, height: 1080 }} {examples.map((ex, i) => (
> <button
{confettiActive && ( key={ex.name}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> onClick={() => setSelectedIndex(i)}
<ConfettiExplosion className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
key={confettiKey} i === selectedIndex
portal={false} ? "bg-primary text-primary-foreground"
force={0.8} : "hover:bg-muted"
duration={3500} }`}
particleCount={400} >
particleSize={8} {ex.name}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]} </button>
width={1600} ))}
height="200vh" </nav>
zIndex={1}
onComplete={() => setConfettiActive(false)} {/* Render area */}
/> <div className="flex-1 flex items-start justify-center overflow-auto p-6">
<div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
</div>
)}
<div className="p-6 relative z-10">
<p className="text-xs text-muted-foreground mb-4">
{selected.description}
</p>
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div> </div>
)}
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div> </div>
</div> </div>
</div> </div>
+349
View File
@@ -508,4 +508,353 @@ export const examples: Example[] = [
}, },
}, },
}, },
// =========================================================================
// Advanced: Registration form with cross-field validation & $template
// =========================================================================
{
name: "Registration Form",
description:
"Cross-field validation, $template preview, and validateForm action",
spec: {
root: "card",
state: {
form: {
name: "",
email: "",
password: "",
confirmPassword: "",
accountType: "personal",
company: "",
},
result: null,
},
elements: {
card: {
type: "Card",
props: {
title: "Create Account",
description: "Fill out the form below to register",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"preview",
"sep0",
"nameInput",
"emailInput",
"passwordInput",
"confirmInput",
"sep1",
"accountTypeRadio",
"companyInput",
"sep2",
"actions",
"statusText",
],
},
// $template live preview
preview: {
type: "Text",
props: {
text: {
$template: "Welcome, ${/form/name}! Your email: ${/form/email}",
},
variant: "muted",
},
visible: { $state: "/form/name", neq: "" },
children: [],
},
sep0: { type: "Separator", props: { orientation: null }, children: [] },
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Jane Doe",
value: { $bindState: "/form/name" },
checks: [
{ type: "required", message: "Name is required" },
{
type: "minLength",
args: { min: 2 },
message: "Name must be at least 2 characters",
},
],
validateOn: "blur",
},
children: [],
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "jane@example.com",
value: { $bindState: "/form/email" },
checks: [
{ type: "required", message: "Email is required" },
{ type: "email", message: "Enter a valid email address" },
],
validateOn: "blur",
},
children: [],
},
passwordInput: {
type: "Input",
props: {
label: "Password",
name: "password",
type: "password",
placeholder: "At least 8 characters",
value: { $bindState: "/form/password" },
checks: [
{ type: "required", message: "Password is required" },
{
type: "minLength",
args: { min: 8 },
message: "Password must be at least 8 characters",
},
],
validateOn: "blur",
},
children: [],
},
confirmInput: {
type: "Input",
props: {
label: "Confirm Password",
name: "confirmPassword",
type: "password",
placeholder: "Re-enter your password",
value: { $bindState: "/form/confirmPassword" },
checks: [
{ type: "required", message: "Please confirm your password" },
{
type: "matches",
args: { other: { $state: "/form/password" } },
message: "Passwords must match",
},
],
validateOn: "blur",
},
children: [],
},
sep1: { type: "Separator", props: { orientation: null }, children: [] },
accountTypeRadio: {
type: "Radio",
props: {
label: "Account Type",
name: "accountType",
options: ["personal", "business"],
value: { $bindState: "/form/accountType" },
checks: null,
validateOn: null,
},
children: [],
},
companyInput: {
type: "Input",
props: {
label: "Company Name",
name: "company",
type: "text",
placeholder: "Acme Inc.",
value: { $bindState: "/form/company" },
checks: [
{
type: "requiredIf",
args: { field: { $state: "/form/accountType" } },
message: "Company name is required for business accounts",
},
],
validateOn: "blur",
},
visible: { $state: "/form/accountType", eq: "business" },
children: [],
},
sep2: { type: "Separator", props: { orientation: null }, children: [] },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["submitBtn"],
},
submitBtn: {
type: "Button",
props: { label: "Register", variant: "primary", disabled: null },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
// Validation result
statusText: {
type: "Alert",
props: {
title: "Validation Result",
message: {
$cond: { $state: "/result/valid", eq: true },
$then: "All fields are valid -- ready to submit!",
$else: "Please fix the errors above before submitting.",
},
type: {
$cond: { $state: "/result/valid", eq: true },
$then: "success",
$else: "error",
},
},
visible: { $state: "/result", neq: null },
children: [],
},
},
},
},
// =========================================================================
// Advanced: Cascading selects with watchers & $computed
// =========================================================================
{
name: "Cascading Selects",
description:
"Watchers reset dependent fields, $computed derives display values",
spec: {
root: "card",
state: {
form: { country: "", city: "" },
availableCities: [],
},
elements: {
card: {
type: "Card",
props: {
title: "Shipping Address",
description: "Select your country to load available cities",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"countrySelect",
"citySelect",
"sep",
"addressPreview",
"templatePreview",
],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK", "Germany", "Japan"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
watch: {
"/form/country": [
{
action: "setState",
params: {
statePath: "/availableCities",
value: {
$computed: "citiesForCountry",
args: { country: { $state: "/form/country" } },
},
},
},
{
action: "setState",
params: { statePath: "/form/city", value: "" },
},
],
},
children: [],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: { $state: "/availableCities" },
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
sep: { type: "Separator", props: { orientation: null }, children: [] },
// $computed formatted address
addressPreview: {
type: "Heading",
props: {
text: {
$computed: "formatAddress",
args: {
city: { $state: "/form/city" },
country: { $state: "/form/country" },
},
},
level: "h3",
},
children: [],
},
// $template string interpolation
templatePreview: {
type: "Text",
props: {
text: {
$template:
"Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
},
variant: "muted",
},
visible: { $state: "/form/country", neq: "" },
children: [],
},
},
},
},
]; ];
+9
View File
@@ -13,4 +13,13 @@ export const catalog = defineCatalog(schema, {
description: "Fire confetti", description: "Fire confetti",
}, },
}, },
functions: {
formatAddress: {
description:
"Formats country and city into a single address string like 'City, Country'",
},
citiesForCountry: {
description: "Returns an array of city names for the given country code",
},
},
}); });
+27 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { defineRegistry } from "@json-render/react"; import { defineRegistry } from "@json-render/react";
import type { ComputedFunction } from "@json-render/core";
import { shadcnComponents } from "@json-render/shadcn"; import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog"; import { catalog } from "./catalog";
@@ -13,6 +14,14 @@ export function onConfetti(cb: () => void) {
}; };
} }
const cityData: Record<string, string[]> = {
US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
};
export const { registry } = defineRegistry(catalog, { export const { registry } = defineRegistry(catalog, {
components: { components: {
...shadcnComponents, ...shadcnComponents,
@@ -24,6 +33,23 @@ export const { registry } = defineRegistry(catalog, {
}, },
}); });
export const actionHandlers: Record<string, () => void> = { export const actionHandlers: Record<
string,
(params: Record<string, unknown>) => void
> = {
confetti: () => confettiListener?.(), confetti: () => confettiListener?.(),
}; };
export const computedFunctions: Record<string, ComputedFunction> = {
formatAddress: (args) => {
const city = (args.city as string) ?? "";
const country = (args.country as string) ?? "";
if (!city && !country) return "No location selected";
if (!city) return country;
return `${city}, ${country}`;
},
citiesForCountry: (args) => {
const country = (args.country as string) ?? "";
return cityData[country] ?? [];
},
};
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@json-render multi-renderer demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
#renderer-root {
max-width: 640px;
margin: 0 auto;
padding: 24px;
}
</style>
</head>
<body>
<div id="renderer-root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "vite-renderers",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "portless vite-renderers.json-render vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/vue": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vue": "^3.5.29",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}
+30
View File
@@ -0,0 +1,30 @@
import "./shared/styles.css";
import { demoSpec } from "./spec";
type Renderer = "vue" | "react";
const container = document.getElementById("renderer-root") as HTMLElement;
let unmountCurrent: (() => void) | null = null;
async function switchTo(renderer: Renderer) {
unmountCurrent?.();
container.innerHTML = "";
if (renderer === "vue") {
const mod = await import("./vue/mount.ts");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
const mod = await import("./react/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
}
}
// The RendererTabs component (rendered by JSON renderer) dispatches this event
document.addEventListener("switch-renderer", (e: Event) => {
switchTo((e as CustomEvent<string>).detail as Renderer);
});
// Default: Vue
switchTo("vue");
+51
View File
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import type { Spec } from "@json-render/core";
import {
StateProvider,
ActionProvider,
VisibilityProvider,
ValidationProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/react";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const { registry } = defineRegistry(catalog, {
components,
actions: actionStubs,
});
function DemoRenderer({ spec }: { spec: Spec }) {
const { get, set } = useStateStore();
const handlers = useMemo(() => makeHandlers(get, set), [get, set]);
return (
<ActionProvider handlers={handlers}>
<VisibilityProvider>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
);
}
export default function App({
initialRenderer = "vue",
spec,
}: {
initialRenderer?: string;
spec: Spec;
}) {
return (
<div className={`renderer-${initialRenderer}`}>
<StateProvider
initialState={{ ...spec.state, renderer: initialRenderer }}
>
<DemoRenderer spec={spec} />
</StateProvider>
</div>
);
}
@@ -0,0 +1,5 @@
import { schema } from "@json-render/react/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
@@ -0,0 +1,15 @@
import { createRoot, type Root } from "react-dom/client";
import type { Spec } from "@json-render/core";
import App from "./App";
let root: Root | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
root = createRoot(container);
root.render(<App initialRenderer={renderer} spec={spec} />);
}
export function unmount() {
root?.unmount();
root = null;
}
@@ -0,0 +1,155 @@
import type { Components } from "@json-render/react";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => (
<div
className={[
"json-render-stack",
props.direction === "horizontal" && "json-render-stack--horizontal",
props.align && `json-render-stack--align-${props.align}`,
]
.filter(Boolean)
.join(" ")}
style={{
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
}}
>
{children}
</div>
),
Card: ({ props, children }) => (
<div className="json-render-card">
{props.title && (
<div className="json-render-card-title-wrap">
<h2 className="json-render-card-title">{props.title}</h2>
</div>
)}
{props.subtitle && (
<p className="json-render-card-subtitle">{props.subtitle}</p>
)}
{children}
</div>
),
Text: ({ props }) => (
<span
className={[
"json-render-text",
props.size && props.size !== "md" && `json-render-text--${props.size}`,
props.weight &&
props.weight !== "normal" &&
`json-render-text--${props.weight}`,
]
.filter(Boolean)
.join(" ")}
style={props.color ? { color: props.color } : undefined}
>
{String(props.content ?? "")}
</span>
),
Button: ({ props, emit }) => (
<button
disabled={props.disabled}
onClick={() => emit("press")}
className={[
"json-render-button",
props.variant && `json-render-button--${props.variant}`,
]
.filter(Boolean)
.join(" ")}
>
{props.label}
</button>
),
Badge: ({ props }) => (
<span
className="json-render-badge"
style={
props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined
}
>
{props.label}
</span>
),
ListItem: ({ props, emit }) => (
<div
onClick={() => emit("press")}
className={[
"json-render-list-item",
props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" ")}
>
<div
className={[
"json-render-list-item-check",
props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" ")}
>
{props.completed ? "✓" : ""}
</div>
<span
className={[
"json-render-list-item-text",
props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" ")}
>
{props.title}
</span>
</div>
),
RendererBadge: ({ props }) => (
<span className="json-render-renderer-badge">
<span className="json-render-renderer-dot" />
{props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React"}
</span>
),
RendererTabs: ({ props, emit }) => (
<div className="json-render-renderer-tabs-wrapper">
<span className="json-render-renderer-tabs-label">Render</span>
<div className="json-render-renderer-tabs">
<button
onClick={() => emit("pressVue")}
className={[
"json-render-renderer-tab",
props.renderer === "vue" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Vue
</button>
<button
onClick={() => emit("pressReact")}
className={[
"json-render-renderer-tab",
props.renderer === "react" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
React
</button>
</div>
</div>
),
};
@@ -0,0 +1,99 @@
import { z } from "zod";
/**
* Shared catalog definition — imported by both vue/catalog.ts and react/catalog.ts.
* Each renderer calls schema.createCatalog(catalogDef) with its own schema instance.
*/
export const catalogDef = {
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().optional(),
completed: z.boolean().optional(),
}),
slots: [],
description: "A single item in a list",
},
RendererTabs: {
props: z.object({ renderer: z.string() }),
slots: [],
description:
"Segmented tab control for switching between Vue and React renderers",
},
RendererBadge: {
props: z.object({ renderer: z.string() }),
slots: [],
description: "Badge indicating which renderer is currently active",
},
},
actions: {
increment: {
params: z.object({}),
description: "Increment the counter by 1",
},
decrement: {
params: z.object({}),
description: "Decrement the counter by 1",
},
reset: { params: z.object({}), description: "Reset the counter to 0" },
toggleItem: {
params: z.object({ index: z.number() }),
description: "Toggle the completed state of a todo item",
},
switchToVue: {
params: z.object({}),
description: "Switch to the Vue renderer",
},
switchToReact: {
params: z.object({}),
description: "Switch to the React renderer",
},
},
};
@@ -0,0 +1,50 @@
type Get = (path: string) => unknown;
type Set = (path: string, value: unknown) => void;
/** Stub actions for defineRegistry (no-ops; real logic is in makeHandlers) */
export const actionStubs = {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
switchToVue: async () => {},
switchToReact: async () => {},
};
/** Creates action handlers that close over the state store's get/set */
export function makeHandlers(get: Get, set: Set) {
return {
increment: async () => {
set("/count", Number(get("/count") || 0) + 1);
},
decrement: async () => {
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
},
reset: async () => {
set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).slice();
const item = todos[index];
if (item) todos[index] = { ...item, completed: !item.completed };
set("/todos", todos);
},
switchToVue: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "vue" }),
);
},
switchToReact: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "react" }),
);
},
};
}
@@ -0,0 +1,248 @@
/* ---- Stack ---------------------------------------------------------------- */
.json-render-stack {
display: flex;
flex-direction: column;
align-items: stretch;
}
.json-render-stack--horizontal {
flex-direction: row;
align-items: center;
}
.json-render-stack--align-start { align-items: flex-start; }
.json-render-stack--align-center { align-items: center; }
.json-render-stack--align-end { align-items: flex-end; }
/* ---- Card ----------------------------------------------------------------- */
.json-render-card {
background-color: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.json-render-card-title-wrap {
margin-bottom: 4px;
}
.json-render-card-title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
.json-render-card-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px 0;
}
/* ---- Text ----------------------------------------------------------------- */
.json-render-text {
font-size: 14px;
font-weight: 400;
color: #111827;
}
.json-render-text--sm { font-size: 12px; }
.json-render-text--lg { font-size: 16px; }
.json-render-text--xl { font-size: 24px; }
.json-render-text--medium { font-weight: 500; }
.json-render-text--bold { font-weight: 700; }
/* ---- Button --------------------------------------------------------------- */
.json-render-button {
padding: 8px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 500;
font-size: 14px;
transition: background 0.15s;
background-color: #3b82f6;
color: white;
}
.json-render-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.json-render-button--primary {
background-color: #3b82f6;
color: white;
}
.json-render-button--secondary {
background-color: #f3f4f6;
color: #374151;
}
.json-render-button--danger {
background-color: #fee2e2;
color: #dc2626;
}
/* ---- Badge ---------------------------------------------------------------- */
.json-render-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
/* ---- ListItem ------------------------------------------------------------- */
.json-render-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
background-color: #f9fafb;
border: 1px solid #e5e7eb;
}
.json-render-list-item--done {
background-color: #f0fdf4;
border-color: #bbf7d0;
}
.json-render-list-item-check {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #d1d5db;
background-color: transparent;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: white;
}
.json-render-list-item-check--done {
border-color: #16a34a;
background-color: #16a34a;
}
.json-render-list-item-text {
font-size: 14px;
color: #111827;
text-decoration: none;
}
.json-render-list-item-text--done {
color: #6b7280;
text-decoration: line-through;
}
/* ---- RendererBadge -------------------------------------------------------- */
.json-render-renderer-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 10px 15px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
/* default colors; overridden by renderer parent class below */
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
.json-render-renderer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
/* background overridden by renderer parent class below */
background-color: #0369a1;
}
/* ---- RendererTabs --------------------------------------------------------- */
.json-render-renderer-tabs-wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.json-render-renderer-tabs-label {
font-size: 13px;
color: #6b7280;
font-weight: 500;
}
.json-render-renderer-tabs {
display: inline-flex;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.json-render-renderer-tab {
padding: 6px 16px;
border: none;
border-right: 0;
cursor: pointer;
font-size: 13px;
font-weight: 500;
background-color: white;
color: #374151;
transition: background 0.15s;
}
.json-render-renderer-tab:first-child {
border-right: 1px solid #e5e7eb;
}
/* ---- Renderer-specific overrides ----------------------------------------- */
.renderer-vue .json-render-renderer-badge {
color: #42b883;
background-color: #42b88318;
border-color: #42b88340;
}
.renderer-vue .json-render-renderer-dot {
background-color: #42b883;
}
.renderer-vue .json-render-renderer-tab--active {
background-color: #42b883;
color: white;
}
.renderer-react .json-render-renderer-badge {
color: #149eca;
background-color: #149eca18;
border-color: #149eca40;
}
.renderer-react .json-render-renderer-dot {
background-color: #149eca;
}
.renderer-react .json-render-renderer-tab--active {
background-color: #149eca;
color: white;
}
+126
View File
@@ -0,0 +1,126 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
renderer: "vue",
count: 0,
todos: [
{ id: 1, title: "Learn JSON Render", completed: true },
{
id: 2,
title: "Try @json-render/vue or @json-render/react",
completed: false,
},
{ id: 3, title: "Build something awesome", completed: false },
],
},
elements: {
root: {
type: "Stack",
props: { gap: 24, padding: 24, direction: "vertical" },
children: [
"demo-title",
"renderer-tabs",
"renderer-badge",
"counter-card",
"milestone-badge",
"todos-card",
],
},
"demo-title": {
type: "Text",
props: {
content: "@json-render multi-renderer demo",
size: "xl",
weight: "bold",
},
},
"renderer-badge": {
type: "RendererBadge",
props: { renderer: { $state: "/renderer" } },
},
"renderer-tabs": {
type: "RendererTabs",
props: { renderer: { $state: "/renderer" } },
on: {
pressVue: { action: "switchToVue" },
pressReact: { action: "switchToReact" },
},
},
// ---- Counter card ----
"counter-card": {
type: "Card",
props: {
title: "Counter",
subtitle: "Click the buttons to change the count",
},
children: ["counter-body"],
},
"counter-body": {
type: "Stack",
props: { gap: 12, direction: "horizontal", align: "center" },
children: [
"decrement-btn",
"counter-value",
"increment-btn",
"reset-btn",
],
},
"decrement-btn": {
type: "Button",
props: { label: "−", variant: "secondary" },
on: { press: { action: "decrement" } },
},
"counter-value": {
type: "Text",
props: {
content: { $state: "/count" },
size: "xl",
weight: "bold",
},
},
"increment-btn": {
type: "Button",
props: { label: "+", variant: "primary" },
on: { press: { action: "increment" } },
},
"reset-btn": {
type: "Button",
props: { label: "Reset", variant: "danger" },
on: { press: { action: "reset" } },
},
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
// ---- Todos card ----
"todos-card": {
type: "Card",
props: { title: "Todo List", subtitle: "Your tasks" },
children: ["todos-list"],
},
"todos-list": {
type: "Stack",
props: { gap: 8, direction: "vertical" },
repeat: { statePath: "/todos", key: "id" },
children: ["todo-item"],
},
"todo-item": {
type: "ListItem",
props: {
title: { $item: "title" },
completed: { $item: "completed" },
},
on: {
press: { action: "toggleItem", params: { index: { $index: true } } },
},
},
},
};
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { Spec } from "@json-render/core";
import { StateProvider } from "@json-render/vue";
import DemoRenderer from "./DemoRenderer.vue";
const props = defineProps<{
initialRenderer?: string; spec: Spec }>();
const initialState = {
...props.spec.state,
renderer: props.initialRenderer ?? "vue" };
</script>
<template>
<div :class="`renderer-${props.initialRenderer ?? 'vue'}`">
<StateProvider :initial-state="initialState">
<DemoRenderer :spec="props.spec" />
</StateProvider>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Spec } from "@json-render/core";
import {
ActionProvider, ValidationProvider, VisibilityProvider,
Renderer, defineRegistry, useStateStore,
} from "@json-render/vue";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const props = defineProps<{ spec: Spec }>();
const { get, set } = useStateStore();
const { registry } = defineRegistry(catalog, { components, actions: actionStubs });
const handlers = makeHandlers(get, set);
</script>
<template>
<ActionProvider :handlers="handlers">
<VisibilityProvider>
<ValidationProvider>
<Renderer :spec="props.spec" :registry="registry" />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
</template>
@@ -0,0 +1,5 @@
import { schema } from "@json-render/vue/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
+15
View File
@@ -0,0 +1,15 @@
import { createApp, type App } from "vue";
import type { Spec } from "@json-render/core";
import VueApp from "./App.vue";
let app: App | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
app = createApp(VueApp, { initialRenderer: renderer, spec });
app.mount(container);
}
export function unmount() {
app?.unmount();
app = null;
}
+166
View File
@@ -0,0 +1,166 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) =>
h(
"div",
{
class: [
"json-render-stack",
props.direction === "horizontal" && "json-render-stack--horizontal",
props.align && `json-render-stack--align-${props.align}`,
]
.filter(Boolean)
.join(" "),
style: {
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
},
},
children,
),
Card: ({ props, children }) =>
h("div", { class: "json-render-card" }, [
props.title &&
h("div", { class: "json-render-card-title-wrap" }, [
h("h2", { class: "json-render-card-title" }, props.title),
]),
props.subtitle &&
h("p", { class: "json-render-card-subtitle" }, props.subtitle),
children,
]),
Text: ({ props }) =>
h(
"span",
{
class: [
"json-render-text",
props.size &&
props.size !== "md" &&
`json-render-text--${props.size}`,
props.weight &&
props.weight !== "normal" &&
`json-render-text--${props.weight}`,
]
.filter(Boolean)
.join(" "),
style: props.color ? { color: props.color } : undefined,
},
String(props.content ?? ""),
),
Button: ({ props, emit }) =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
class: [
"json-render-button",
props.variant && `json-render-button--${props.variant}`,
]
.filter(Boolean)
.join(" "),
},
props.label,
),
Badge: ({ props }) =>
h(
"span",
{
class: "json-render-badge",
style: props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined,
},
props.label,
),
ListItem: ({ props, emit }) =>
h(
"div",
{
onClick: () => emit("press"),
class: [
"json-render-list-item",
props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" "),
},
[
h(
"div",
{
class: [
"json-render-list-item-check",
props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" "),
},
props.completed ? "✓" : "",
),
h(
"span",
{
class: [
"json-render-list-item-text",
props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" "),
},
props.title,
),
],
),
RendererBadge: ({ props }) =>
h("span", { class: "json-render-renderer-badge" }, [
h("span", { class: "json-render-renderer-dot" }),
props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React",
]),
RendererTabs: ({ props, emit }) =>
h("div", { class: "json-render-renderer-tabs-wrapper" }, [
h("span", { class: "json-render-renderer-tabs-label" }, "Render"),
h("div", { class: "json-render-renderer-tabs" }, [
h(
"button",
{
onClick: () => emit("pressVue"),
class: [
"json-render-renderer-tab",
props.renderer === "vue" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"Vue",
),
h(
"button",
{
onClick: () => emit("pressReact"),
class: [
"json-render-renderer-tab",
props.renderer === "react" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"React",
),
]),
]),
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [vue(), react({ include: /\.tsx$/ })],
});
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>json-render vue example</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f9fafb;
color: #111827;
min-height: 100vh;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "vue",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "portless vue.json-render vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/vue": "workspace:*",
"vue": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.2.5"
}
}
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { StateProvider } from "@json-render/vue";
import { demoSpec } from "./lib/spec";
import DemoRenderer from "./DemoRenderer.vue";
const initialState = demoSpec.state ?? {};
</script>
<template>
<!-- StateProvider sets up the reactive state store for the whole tree -->
<StateProvider :initial-state="initialState">
<DemoRenderer />
</StateProvider>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import {
ActionProvider,
ValidationProvider,
VisibilityProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/vue";
import { catalog } from "./lib/catalog";
import { components } from "./lib/registry";
import { demoSpec } from "./lib/spec";
// Access the state store provided by the parent StateProvider
const { get, set } = useStateStore();
// Build registry — include stub actions to satisfy catalog types.
// The actual logic runs in the handlers below, which have direct
// access to the state store.
const { registry } = defineRegistry(catalog, {
components,
actions: {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
},
});
// Action handlers — close over the state store's get/set so they
// can read and write state directly without needing an external store.
const handlers = {
increment: async () => {
set("/count", Number(get("/count") || 0) + 1);
},
decrement: async () => {
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
},
reset: async () => {
set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).slice();
const item = todos[index];
if (item) {
todos[index] = { ...item, completed: !item.completed };
}
set("/todos", todos);
},
};
</script>
<template>
<ActionProvider :handlers="handlers">
<VisibilityProvider>
<ValidationProvider>
<Renderer :spec="demoSpec" :registry="registry" />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
</template>
+92
View File
@@ -0,0 +1,92 @@
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = schema.createCatalog({
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().optional(),
completed: z.boolean().optional(),
}),
slots: [],
description: "A single item in a list",
},
Input: {
props: z.object({
value: z.string().optional(),
placeholder: z.string().optional(),
}),
slots: [],
description: "A text input field that supports two-way state binding",
},
},
actions: {
increment: {
params: z.object({}),
description: "Increment the counter by 1",
},
decrement: {
params: z.object({}),
description: "Decrement the counter by 1",
},
reset: {
params: z.object({}),
description: "Reset the counter to 0",
},
toggleItem: {
params: z.object({
index: z.number(),
}),
description: "Toggle the completed state of a todo item",
},
},
});
export type AppCatalog = typeof catalog;
+213
View File
@@ -0,0 +1,213 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import { useBoundProp } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => {
const isHorizontal = props.direction === "horizontal";
return h(
"div",
{
style: {
display: "flex",
flexDirection: isHorizontal ? "row" : "column",
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
alignItems: props.align ?? (isHorizontal ? "center" : "stretch"),
},
},
children,
);
},
Card: ({ props, children }) =>
h(
"div",
{
style: {
backgroundColor: "white",
borderRadius: "12px",
border: "1px solid #e5e7eb",
padding: "20px",
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
},
},
[
props.title &&
h("div", { style: { marginBottom: "4px" } }, [
h(
"h2",
{
style: {
fontSize: "16px",
fontWeight: "600",
color: "#111827",
margin: 0,
},
},
props.title,
),
]),
props.subtitle &&
h(
"p",
{
style: {
fontSize: "13px",
color: "#6b7280",
margin: "0 0 12px 0",
},
},
props.subtitle,
),
children,
],
),
Text: ({ props }) => {
const sizeMap: Record<string, string> = {
sm: "12px",
md: "14px",
lg: "16px",
xl: "24px",
};
const weightMap: Record<string, string> = {
normal: "400",
medium: "500",
bold: "700",
};
return h(
"span",
{
style: {
fontSize: sizeMap[props.size ?? "md"] ?? "14px",
fontWeight: weightMap[props.weight ?? "normal"] ?? "400",
color: props.color ?? "#111827",
},
},
String(props.content ?? ""),
);
},
Button: ({ props, emit }) =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
style: {
padding: "8px 16px",
borderRadius: "8px",
border: "none",
cursor: props.disabled ? "not-allowed" : "pointer",
fontWeight: "500",
fontSize: "14px",
transition: "background 0.15s",
opacity: props.disabled ? "0.5" : "1",
backgroundColor:
props.variant === "danger"
? "#fee2e2"
: props.variant === "secondary"
? "#f3f4f6"
: "#3b82f6",
color:
props.variant === "danger"
? "#dc2626"
: props.variant === "secondary"
? "#374151"
: "white",
},
},
props.label,
),
Badge: ({ props }) =>
h(
"span",
{
style: {
display: "inline-block",
padding: "4px 12px",
borderRadius: "999px",
fontSize: "13px",
fontWeight: "500",
backgroundColor: props.color ? `${props.color}20` : "#e0f2fe",
color: props.color ?? "#0369a1",
border: `1px solid ${props.color ? `${props.color}40` : "#bae6fd"}`,
},
},
props.label,
),
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
return h("input", {
value: value ?? "",
placeholder: props.placeholder as string | undefined,
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
style: {
padding: "8px 12px",
borderRadius: "8px",
border: "1px solid #d1d5db",
fontSize: "14px",
outline: "none",
width: "100%",
boxSizing: "border-box",
},
});
},
ListItem: ({ props, emit }) =>
h(
"div",
{
onClick: () => emit("press"),
style: {
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 12px",
borderRadius: "8px",
cursor: "pointer",
backgroundColor: props.completed ? "#f0fdf4" : "#f9fafb",
border: `1px solid ${props.completed ? "#bbf7d0" : "#e5e7eb"}`,
},
},
[
h(
"div",
{
style: {
width: "18px",
height: "18px",
borderRadius: "50%",
border: `2px solid ${props.completed ? "#16a34a" : "#d1d5db"}`,
backgroundColor: props.completed ? "#16a34a" : "transparent",
flexShrink: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "11px",
color: "white",
},
},
props.completed ? "✓" : "",
),
h(
"span",
{
style: {
fontSize: "14px",
color: props.completed ? "#6b7280" : "#111827",
textDecoration: props.completed ? "line-through" : "none",
},
},
props.title,
),
],
),
};
+138
View File
@@ -0,0 +1,138 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
count: 0,
name: "",
todos: [
{ id: 1, title: "Learn Vue 3", completed: true },
{ id: 2, title: "Try @json-render/vue", completed: false },
{ id: 3, title: "Build something awesome", completed: false },
],
},
elements: {
root: {
type: "Stack",
props: { gap: 24, padding: 24, direction: "vertical" },
children: [
"header",
"counter-card",
"milestone-badge",
"todos-card",
"input-card",
],
},
header: {
type: "Text",
props: {
content: "@json-render/vue demo",
size: "xl",
weight: "bold",
},
},
// ---- Counter card ----
"counter-card": {
type: "Card",
props: {
title: "Counter",
subtitle: "Click the buttons to change the count",
},
children: ["counter-body"],
},
"counter-body": {
type: "Stack",
props: { gap: 12, direction: "horizontal", align: "center" },
children: [
"decrement-btn",
"counter-value",
"increment-btn",
"reset-btn",
],
},
"decrement-btn": {
type: "Button",
props: { label: "−", variant: "secondary" },
on: { press: { action: "decrement" } },
},
"counter-value": {
type: "Text",
props: {
content: { $state: "/count" },
size: "xl",
weight: "bold",
},
},
"increment-btn": {
type: "Button",
props: { label: "+", variant: "primary" },
on: { press: { action: "increment" } },
},
"reset-btn": {
type: "Button",
props: { label: "Reset", variant: "danger" },
on: { press: { action: "reset" } },
},
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
// ---- Todos card ----
"todos-card": {
type: "Card",
props: { title: "Todo List", subtitle: "Your tasks" },
children: ["todos-list"],
},
"todos-list": {
type: "Stack",
props: { gap: 8, direction: "vertical" },
repeat: { statePath: "/todos", key: "id" },
children: ["todo-item"],
},
"todo-item": {
type: "ListItem",
props: {
title: { $item: "title" },
completed: { $item: "completed" },
},
on: {
press: { action: "toggleItem", params: { index: { $index: true } } },
},
},
// ---- Bound Input card (useBoundProp demo) ----
"input-card": {
type: "Card",
props: {
title: "Bound Input",
subtitle: "Type to update state — the display text reacts in real time",
},
children: ["input-body"],
},
"input-body": {
type: "Stack",
props: { gap: 12, direction: "vertical" },
children: ["name-input", "name-display"],
},
"name-input": {
type: "Input",
props: {
value: { $bindState: "/name" },
placeholder: "Enter your name…",
},
},
"name-display": {
type: "Text",
props: {
content: { $state: "/name" },
size: "md",
color: "#6b7280",
},
},
},
};
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --concurrency 15", "dev": "turbo run dev --concurrency 20",
"lint": "turbo run lint", "lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx}\"", "format": "prettier --write \"**/*.{ts,tsx}\"",
"type-check": "turbo run check-types", "type-check": "turbo run check-types",
+5 -1
View File
@@ -72,7 +72,11 @@ export {
} from "./visibility"; } from "./visibility";
// Prop Expressions // Prop Expressions
export type { PropExpression, PropResolutionContext } from "./props"; export type {
PropExpression,
PropResolutionContext,
ComputedFunction,
} from "./props";
export { export {
resolvePropValue, resolvePropValue,
+165 -1
View File
@@ -1,9 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { import {
resolvePropValue, resolvePropValue,
resolveElementProps, resolveElementProps,
resolveBindings, resolveBindings,
resolveActionParam, resolveActionParam,
_resetWarnedComputedFns,
_resetWarnedTemplatePaths,
} from "./props"; } from "./props";
import type { PropResolutionContext } from "./props"; import type { PropResolutionContext } from "./props";
@@ -498,3 +500,165 @@ describe("resolveActionParam", () => {
expect(resolveActionParam(null, ctx)).toBeNull(); expect(resolveActionParam(null, ctx)).toBeNull();
}); });
}); });
// =============================================================================
// $computed expressions
// =============================================================================
describe("$computed expressions", () => {
it("calls a registered function with resolved args", () => {
const ctx: PropResolutionContext = {
stateModel: { form: { firstName: "Jane", lastName: "Doe" } },
functions: {
fullName: (args) => `${args.first} ${args.last}`,
},
};
expect(
resolvePropValue(
{
$computed: "fullName",
args: {
first: { $state: "/form/firstName" },
last: { $state: "/form/lastName" },
},
},
ctx,
),
).toBe("Jane Doe");
});
it("calls function with no args", () => {
const ctx: PropResolutionContext = {
stateModel: {},
functions: {
timestamp: () => 1234567890,
},
};
expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890);
});
it("returns undefined for unknown function", () => {
const ctx: PropResolutionContext = {
stateModel: {},
functions: {},
};
expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined();
});
it("returns undefined when no functions in context", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined();
});
it("deduplicates warnings for the same unknown function", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: {}, functions: {} };
resolvePropValue({ $computed: "dedupTest" }, ctx);
resolvePropValue({ $computed: "dedupTest" }, ctx);
const calls = warnSpy.mock.calls.filter((c) =>
String(c[0]).includes("dedupTest"),
);
expect(calls).toHaveLength(1);
warnSpy.mockRestore();
});
it("resolves nested expressions in args", () => {
const ctx: PropResolutionContext = {
stateModel: { active: true, values: { a: 10, b: 20 } },
functions: {
conditionalSum: (args) => {
if (args.enabled) return (args.x as number) + (args.y as number);
return 0;
},
},
};
expect(
resolvePropValue(
{
$computed: "conditionalSum",
args: {
enabled: { $state: "/active" },
x: { $state: "/values/a" },
y: { $state: "/values/b" },
},
},
ctx,
),
).toBe(30);
});
});
// =============================================================================
// $template expressions
// =============================================================================
describe("$template expressions", () => {
it("interpolates state values into a string", () => {
const ctx: PropResolutionContext = {
stateModel: { user: { name: "Alice" }, count: 3 },
};
expect(
resolvePropValue(
{ $template: "Hello, ${/user/name}! You have ${/count} messages." },
ctx,
),
).toBe("Hello, Alice! You have 3 messages.");
});
it("replaces missing paths with empty string", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !");
});
it("handles template with no interpolations", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe(
"No variables here",
);
});
it("handles multiple references to the same path", () => {
const ctx: PropResolutionContext = {
stateModel: { x: "A" },
};
expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe(
"A and A",
);
});
it("converts non-string values to strings", () => {
const ctx: PropResolutionContext = {
stateModel: { num: 42, bool: true },
};
expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe(
"42 is true",
);
});
it("warns when path does not start with /", () => {
_resetWarnedTemplatePaths();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
expect(result).toBe("Hi Bob!");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('$template path "name"'),
);
warnSpy.mockRestore();
_resetWarnedTemplatePaths();
});
it("deduplicates warnings for the same $template path", () => {
_resetWarnedTemplatePaths();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
const calls = warnSpy.mock.calls.filter((c) =>
String(c[0]).includes('$template path "name"'),
);
expect(calls).toHaveLength(1);
warnSpy.mockRestore();
_resetWarnedTemplatePaths();
});
});
+100 -1
View File
@@ -22,6 +22,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
* repeat item — resolves via `repeatBasePath + path` and exposes the * repeat item — resolves via `repeatBasePath + path` and exposes the
* absolute state path for write-back. * absolute state path for write-back.
* - `{ $cond, $then, $else }` conditionally picks a value * - `{ $cond, $then, $else }` conditionally picks a value
* - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
* registered function with resolved args and returns the result
* - `{ $template: string }` interpolates `${/path}` references in the
* string with values from the state model
* - Any other value is a literal (passthrough) * - Any other value is a literal (passthrough)
*/ */
export type PropExpression<T = unknown> = export type PropExpression<T = unknown> =
@@ -35,7 +39,15 @@ export type PropExpression<T = unknown> =
$cond: VisibilityCondition; $cond: VisibilityCondition;
$then: PropExpression<T>; $then: PropExpression<T>;
$else: PropExpression<T>; $else: PropExpression<T>;
}; }
| { $computed: string; args?: Record<string, unknown> }
| { $template: string };
/**
* Function signature for `$computed` expressions.
* Receives a record of resolved argument values and returns a computed result.
*/
export type ComputedFunction = (args: Record<string, unknown>) => unknown;
/** /**
* Context for resolving prop expressions. * Context for resolving prop expressions.
@@ -45,6 +57,8 @@ export type PropExpression<T = unknown> =
export interface PropResolutionContext extends VisibilityContext { export interface PropResolutionContext extends VisibilityContext {
/** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */ /** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
repeatBasePath?: string; repeatBasePath?: string;
/** Named functions available for `$computed` expressions. */
functions?: Record<string, ComputedFunction>;
} }
// ============================================================================= // =============================================================================
@@ -110,6 +124,47 @@ function isCondExpression(
); );
} }
function isComputedExpression(
value: unknown,
): value is { $computed: string; args?: Record<string, unknown> } {
return (
typeof value === "object" &&
value !== null &&
"$computed" in value &&
typeof (value as Record<string, unknown>).$computed === "string"
);
}
function isTemplateExpression(value: unknown): value is { $template: string } {
return (
typeof value === "object" &&
value !== null &&
"$template" in value &&
typeof (value as Record<string, unknown>).$template === "string"
);
}
// Module-level set to avoid spamming console.warn on every render for the same
// unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX,
// new names are no longer deduplicated (warnings still fire) but the set stops
// growing, preventing unbounded memory use in long-lived processes (e.g. SSR).
const WARNED_COMPUTED_MAX = 100;
const warnedComputedFns = new Set<string>();
/** @internal Test-only: clear the deduplication set for $computed warnings. */
export function _resetWarnedComputedFns(): void {
warnedComputedFns.clear();
}
// Same deduplication pattern for $template paths that don't start with "/".
const WARNED_TEMPLATE_MAX = 100;
const warnedTemplatePaths = new Set<string>();
/** @internal Test-only: clear the deduplication set for $template warnings. */
export function _resetWarnedTemplatePaths(): void {
warnedTemplatePaths.clear();
}
// ============================================================================= // =============================================================================
// Prop Expression Resolution // Prop Expression Resolution
// ============================================================================= // =============================================================================
@@ -193,6 +248,50 @@ export function resolvePropValue(
return resolvePropValue(result ? value.$then : value.$else, ctx); return resolvePropValue(result ? value.$then : value.$else, ctx);
} }
// $computed: call a registered function with resolved args
if (isComputedExpression(value)) {
const fn = ctx.functions?.[value.$computed];
if (!fn) {
if (!warnedComputedFns.has(value.$computed)) {
if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
warnedComputedFns.add(value.$computed);
}
console.warn(`Unknown $computed function: "${value.$computed}"`);
}
return undefined;
}
const resolvedArgs: Record<string, unknown> = {};
if (value.args) {
for (const [key, arg] of Object.entries(value.args)) {
resolvedArgs[key] = resolvePropValue(arg, ctx);
}
}
return fn(resolvedArgs);
}
// $template: interpolate ${/path} references with state values
if (isTemplateExpression(value)) {
return value.$template.replace(
/\$\{([^}]+)\}/g,
(_match, rawPath: string) => {
let path = rawPath;
if (!path.startsWith("/")) {
if (!warnedTemplatePaths.has(path)) {
if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
warnedTemplatePaths.add(path);
}
console.warn(
`$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
);
}
path = "/" + path;
}
const resolved = getByPath(ctx.stateModel, path);
return resolved != null ? String(resolved) : "";
},
);
}
// Arrays: resolve each element // Arrays: resolve each element
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map((item) => resolvePropValue(item, ctx)); return value.map((item) => resolvePropValue(item, ctx));
+65 -1
View File
@@ -900,6 +900,31 @@ Note: state patches appear right after the elements that use them, so the UI fil
"Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.", "Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.",
); );
lines.push(""); lines.push("");
lines.push(
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
);
lines.push(
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
);
lines.push("");
// $computed section — only emit when catalog defines functions
const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
lines.push(
'5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.',
);
lines.push(
' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`',
);
lines.push(" Available functions:");
for (const name of Object.keys(
catalogFunctions as Record<string, unknown>,
)) {
lines.push(` - ${name}`);
}
lines.push("");
}
// Validation section — only emit when at least one component has a `checks` prop // Validation section — only emit when at least one component has a `checks` prop
const hasChecksComponents = allComponents const hasChecksComponents = allComponents
@@ -930,7 +955,19 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push(" - numeric — value must be a number"); lines.push(" - numeric — value must be a number");
lines.push(" - url — valid URL format"); lines.push(" - url — valid URL format");
lines.push( lines.push(
' - matches — must equal another field (args: { "other": "value" })', ' - matches — must equal another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - equalTo — alias for matches (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })',
); );
lines.push(""); lines.push("");
lines.push("Example:"); lines.push("Example:");
@@ -947,6 +984,33 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push(""); lines.push("");
} }
// State watchers section — only emit when actions are available (watchers
// trigger actions, so the section is irrelevant without them).
if (hasCustomActions || hasBuiltInActions) {
lines.push("STATE WATCHERS:");
lines.push(
"Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props.",
);
lines.push(
"Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.",
);
lines.push("");
lines.push(
"Example (cascading select — country changes trigger city loading):",
);
lines.push(
` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`,
);
lines.push("");
lines.push(
"Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).",
);
lines.push(
"IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render.",
);
lines.push("");
}
// Rules // Rules
lines.push("RULES:"); lines.push("RULES:");
const baseRules = const baseRules =
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect } from "vitest";
import type { Spec } from "./types";
import { validateSpec, autoFixSpec } from "./spec-validator";
// =============================================================================
// validateSpec
// =============================================================================
describe("validateSpec", () => {
it("returns valid for a correct spec", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["child1"] },
child1: { type: "Text", props: { text: "hello" }, children: [] },
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("detects missing root", () => {
const spec = {
root: "",
elements: { a: { type: "T", props: {}, children: [] } },
} as Spec;
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "missing_root")).toBe(true);
});
it("detects root_not_found", () => {
const spec: Spec = {
root: "missing",
elements: { a: { type: "T", props: {}, children: [] } },
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true);
});
it("detects empty spec", () => {
const spec: Spec = { root: "r", elements: {} };
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true);
});
it("detects missing_child", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["nonexistent"] },
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
});
it("detects visible_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Text",
props: { visible: { $state: "/show" } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true);
});
it("detects on_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: { on: { press: { action: "doSomething" } } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true);
});
it("detects repeat_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { repeat: { statePath: "/items" } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true);
});
it("detects watch_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Select",
props: {
watch: {
"/form/country": { action: "loadCities" },
},
},
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
const watchIssue = result.issues.find((i) => i.code === "watch_in_props");
expect(watchIssue).toBeDefined();
expect(watchIssue!.elementKey).toBe("root");
});
it("detects orphaned elements when checkOrphans is true", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: [] },
orphan: { type: "Text", props: {}, children: [] },
},
};
const result = validateSpec(spec, { checkOrphans: true });
expect(result.valid).toBe(true);
expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true);
});
});
// =============================================================================
// autoFixSpec
// =============================================================================
describe("autoFixSpec", () => {
it("moves visible from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Text",
props: { text: "hi", visible: { $state: "/show" } },
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).visible,
).toBeUndefined();
expect(fixed.elements.root.visible).toEqual({ $state: "/show" });
expect(fixes.some((f) => f.includes("visible"))).toBe(true);
});
it("moves on from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: { label: "OK", on: { press: { action: "submit" } } },
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).on,
).toBeUndefined();
expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } });
expect(fixes.some((f) => f.includes('"on"'))).toBe(true);
});
it("moves repeat from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { repeat: { statePath: "/items" } },
children: ["child"],
},
child: { type: "Text", props: {}, children: [] },
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).repeat,
).toBeUndefined();
expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" });
expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true);
});
it("moves watch from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Select",
props: {
label: "Country",
watch: {
"/form/country": { action: "loadCities" },
},
},
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).watch,
).toBeUndefined();
expect(fixed.elements.root.watch).toEqual({
"/form/country": { action: "loadCities" },
});
expect(fixes.some((f) => f.includes('"watch"'))).toBe(true);
});
it("returns no fixes for a correct spec", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { direction: "vertical" },
children: [],
watch: { "/x": { action: "y" } },
},
},
};
const { fixes } = autoFixSpec(spec);
expect(fixes).toHaveLength(0);
});
});
+27 -1
View File
@@ -28,7 +28,8 @@ export interface SpecIssue {
| "orphaned_element" | "orphaned_element"
| "empty_spec" | "empty_spec"
| "on_in_props" | "on_in_props"
| "repeat_in_props"; | "repeat_in_props"
| "watch_in_props";
} }
/** /**
@@ -151,6 +152,16 @@ export function validateSpec(
code: "repeat_in_props", code: "repeat_in_props",
}); });
} }
// 3e. `watch` inside props (should be a top-level field)
if (props && "watch" in props && props.watch !== undefined) {
issues.push({
severity: "error",
message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
elementKey: key,
code: "watch_in_props",
});
}
} }
// 4. Orphaned elements (optional) // 4. Orphaned elements (optional)
@@ -248,6 +259,21 @@ export function autoFixSpec(spec: Spec): {
fixes.push(`Moved "repeat" from props to element level on "${key}".`); fixes.push(`Moved "repeat" from props to element level on "${key}".`);
} }
currentProps = fixed.props as Record<string, unknown> | undefined;
if (
currentProps &&
"watch" in currentProps &&
currentProps.watch !== undefined
) {
const { watch, ...restProps } = currentProps;
fixed = {
...fixed,
props: restProps,
watch: watch as UIElement["watch"],
};
fixes.push(`Moved "watch" from props to element level on "${key}".`);
}
fixedElements[key] = fixed; fixedElements[key] = fixed;
} }
+6
View File
@@ -69,6 +69,12 @@ export interface UIElement<
on?: Record<string, ActionBinding | ActionBinding[]>; on?: Record<string, ActionBinding | ActionBinding[]>;
/** Repeat children once per item in a state array */ /** Repeat children once per item in a state array */
repeat?: { statePath: string; key?: string }; repeat?: { statePath: string; key?: string };
/**
* State watchers — maps JSON Pointer state paths to action bindings.
* When the value at a watched path changes, the bound actions fire.
* Useful for cascading dependencies (e.g. country → city option loading).
*/
watch?: Record<string, ActionBinding | ActionBinding[]>;
} }
/** /**
+341
View File
@@ -187,6 +187,226 @@ describe("builtInValidationFunctions", () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe("equalTo", () => {
it("passes when values are equal", () => {
expect(builtInValidationFunctions.equalTo("abc", { other: "abc" })).toBe(
true,
);
});
it("fails when values differ", () => {
expect(builtInValidationFunctions.equalTo("abc", { other: "xyz" })).toBe(
false,
);
});
});
describe("lessThan", () => {
it("passes when value is less than other", () => {
expect(builtInValidationFunctions.lessThan(3, { other: 5 })).toBe(true);
});
it("fails when value equals other", () => {
expect(builtInValidationFunctions.lessThan(5, { other: 5 })).toBe(false);
});
it("fails when value is greater than other", () => {
expect(builtInValidationFunctions.lessThan(7, { other: 5 })).toBe(false);
});
it("coerces numeric string vs number", () => {
expect(builtInValidationFunctions.lessThan("3", { other: 5 })).toBe(true);
});
it("fails coercion when non-numeric string", () => {
expect(builtInValidationFunctions.lessThan("abc", { other: 5 })).toBe(
false,
);
});
it("passes for string comparison (ISO dates)", () => {
expect(
builtInValidationFunctions.lessThan("2026-01-01", {
other: "2026-06-15",
}),
).toBe(true);
});
it("fails for equal strings", () => {
expect(
builtInValidationFunctions.lessThan("2026-01-01", {
other: "2026-01-01",
}),
).toBe(false);
});
it("returns false when value is empty string", () => {
expect(builtInValidationFunctions.lessThan("", { other: 5 })).toBe(false);
});
it("returns false when other is empty string", () => {
expect(builtInValidationFunctions.lessThan(3, { other: "" })).toBe(false);
});
it("returns false when value is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.lessThan("", { other: "abc" })).toBe(
false,
);
});
it("returns false when other is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.lessThan("abc", { other: "" })).toBe(
false,
);
});
it("returns false when other is null", () => {
expect(builtInValidationFunctions.lessThan(3, { other: null })).toBe(
false,
);
});
it("returns false when value is null", () => {
expect(builtInValidationFunctions.lessThan(null, { other: 5 })).toBe(
false,
);
});
it("returns false when other is undefined", () => {
expect(builtInValidationFunctions.lessThan(3, { other: undefined })).toBe(
false,
);
});
});
describe("greaterThan", () => {
it("passes when value is greater than other", () => {
expect(builtInValidationFunctions.greaterThan(7, { other: 5 })).toBe(
true,
);
});
it("fails when value equals other", () => {
expect(builtInValidationFunctions.greaterThan(5, { other: 5 })).toBe(
false,
);
});
it("fails when value is less than other", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: 5 })).toBe(
false,
);
});
it("coerces numeric string vs number", () => {
expect(builtInValidationFunctions.greaterThan("7", { other: 5 })).toBe(
true,
);
});
it("fails coercion when non-numeric string", () => {
expect(builtInValidationFunctions.greaterThan("abc", { other: 5 })).toBe(
false,
);
});
it("passes for string comparison (ISO dates)", () => {
expect(
builtInValidationFunctions.greaterThan("2026-06-15", {
other: "2026-01-01",
}),
).toBe(true);
});
it("fails for lesser strings", () => {
expect(
builtInValidationFunctions.greaterThan("2026-01-01", {
other: "2026-06-15",
}),
).toBe(false);
});
it("returns false when value is empty string", () => {
expect(builtInValidationFunctions.greaterThan("", { other: 5 })).toBe(
false,
);
});
it("returns false when other is empty string", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: "" })).toBe(
false,
);
});
it("returns false when value is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.greaterThan("", { other: "abc" })).toBe(
false,
);
});
it("returns false when other is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.greaterThan("abc", { other: "" })).toBe(
false,
);
});
it("returns false when other is null", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: null })).toBe(
false,
);
});
it("returns false when value is undefined", () => {
expect(
builtInValidationFunctions.greaterThan(undefined, { other: 5 }),
).toBe(false);
});
it("returns false when value is null", () => {
expect(builtInValidationFunctions.greaterThan(null, { other: 5 })).toBe(
false,
);
});
});
describe("requiredIf", () => {
it("passes when condition is falsy (field not required)", () => {
expect(builtInValidationFunctions.requiredIf("", { field: false })).toBe(
true,
);
expect(builtInValidationFunctions.requiredIf("", { field: "" })).toBe(
true,
);
expect(builtInValidationFunctions.requiredIf("", { field: null })).toBe(
true,
);
expect(
builtInValidationFunctions.requiredIf("", { field: undefined }),
).toBe(true);
});
it("fails when condition is truthy and value is empty", () => {
expect(builtInValidationFunctions.requiredIf("", { field: true })).toBe(
false,
);
expect(
builtInValidationFunctions.requiredIf(null, { field: "yes" }),
).toBe(false);
expect(
builtInValidationFunctions.requiredIf(undefined, { field: 1 }),
).toBe(false);
});
it("passes when condition is truthy and value is present", () => {
expect(
builtInValidationFunctions.requiredIf("hello", { field: true }),
).toBe(true);
expect(builtInValidationFunctions.requiredIf(42, { field: true })).toBe(
true,
);
});
});
}); });
describe("runValidationCheck", () => { describe("runValidationCheck", () => {
@@ -391,6 +611,22 @@ describe("check helper", () => {
}); });
}); });
describe("numeric", () => {
it("creates numeric check with default message", () => {
const c = check.numeric();
expect(c.type).toBe("numeric");
expect(c.message).toBe("Must be a number");
});
it("creates numeric check with custom message", () => {
const c = check.numeric("Numbers only");
expect(c.type).toBe("numeric");
expect(c.message).toBe("Numbers only");
});
});
describe("matches", () => { describe("matches", () => {
it("creates matches check with path reference", () => { it("creates matches check with path reference", () => {
const c = check.matches("/password", "Passwords must match"); const c = check.matches("/password", "Passwords must match");
@@ -400,4 +636,109 @@ describe("check helper", () => {
expect(c.message).toBe("Passwords must match"); expect(c.message).toBe("Passwords must match");
}); });
}); });
describe("equalTo", () => {
it("creates equalTo check with path reference", () => {
const c = check.equalTo("/email", "Emails must match");
expect(c.type).toBe("equalTo");
expect(c.args).toEqual({ other: { $state: "/email" } });
expect(c.message).toBe("Emails must match");
});
});
describe("lessThan", () => {
it("creates lessThan check with path reference", () => {
const c = check.lessThan("/maxValue", "Must be less");
expect(c.type).toBe("lessThan");
expect(c.args).toEqual({ other: { $state: "/maxValue" } });
expect(c.message).toBe("Must be less");
});
});
describe("greaterThan", () => {
it("creates greaterThan check with path reference", () => {
const c = check.greaterThan("/minValue");
expect(c.type).toBe("greaterThan");
expect(c.args).toEqual({ other: { $state: "/minValue" } });
});
});
describe("requiredIf", () => {
it("creates requiredIf check with path reference", () => {
const c = check.requiredIf("/toggle", "Required when toggle is on");
expect(c.type).toBe("requiredIf");
expect(c.args).toEqual({ field: { $state: "/toggle" } });
expect(c.message).toBe("Required when toggle is on");
});
});
});
// =============================================================================
// Deep arg resolution in runValidationCheck
// =============================================================================
describe("deep arg resolution", () => {
it("resolves nested $state refs in validation args", () => {
const result = runValidationCheck(
{
type: "matches",
args: { other: { $state: "/form/password" } },
message: "Passwords must match",
},
{
value: "secret123",
stateModel: { form: { password: "secret123" } },
},
);
expect(result.valid).toBe(true);
});
it("resolves $state in cross-field lessThan check", () => {
const result = runValidationCheck(
{
type: "lessThan",
args: { other: { $state: "/form/maxPrice" } },
message: "Must be less than max price",
},
{
value: 50,
stateModel: { form: { maxPrice: 100 } },
},
);
expect(result.valid).toBe(true);
});
it("resolves $state in requiredIf check", () => {
const result = runValidationCheck(
{
type: "requiredIf",
args: { field: { $state: "/form/enableEmail" } },
message: "Email is required",
},
{
value: "",
stateModel: { form: { enableEmail: true } },
},
);
expect(result.valid).toBe(false);
});
it("passes requiredIf when condition is false", () => {
const result = runValidationCheck(
{
type: "requiredIf",
args: { field: { $state: "/form/enableEmail" } },
message: "Email is required",
},
{
value: "",
stateModel: { form: { enableEmail: false } },
},
);
expect(result.valid).toBe(true);
});
}); });
+104 -4
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { DynamicValue, StateModel, VisibilityCondition } from "./types"; import type { DynamicValue, StateModel, VisibilityCondition } from "./types";
import { DynamicValueSchema, resolveDynamicValue } from "./types"; import { DynamicValueSchema, resolveDynamicValue } from "./types";
import { VisibilityConditionSchema, evaluateVisibility } from "./visibility"; import { VisibilityConditionSchema, evaluateVisibility } from "./visibility";
import { resolvePropValue } from "./props";
/** /**
* Validation check definition * Validation check definition
@@ -63,6 +64,14 @@ export interface ValidationFunctionDefinition {
description?: string; description?: string;
} }
const matchesImpl: ValidationFunction = (
value: unknown,
args?: Record<string, unknown>,
) => {
const other = args?.other;
return value === other;
};
/** /**
* Built-in validation functions * Built-in validation functions
*/ */
@@ -164,9 +173,64 @@ export const builtInValidationFunctions: Record<string, ValidationFunction> = {
/** /**
* Check if value matches another field * Check if value matches another field
*/ */
matches: (value: unknown, args?: Record<string, unknown>) => { matches: matchesImpl,
/**
* Alias for matches with a more descriptive name for cross-field equality
*/
equalTo: matchesImpl,
/**
* Check if value is less than another field's value.
* Supports numbers, strings (useful for ISO date comparison), and
* cross-type numeric coercion (e.g. string "3" vs number 5).
*/
lessThan: (value: unknown, args?: Record<string, unknown>) => {
const other = args?.other; const other = args?.other;
return value === other; if (value == null || other == null || value === "" || other === "")
return false;
if (typeof value === "number" && typeof other === "number")
return value < other;
if (typeof value === "string" && typeof other === "string")
return value < other;
const numVal = Number(value);
const numOther = Number(other);
if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther;
return false;
},
/**
* Check if value is greater than another field's value.
* Supports numbers, strings (useful for ISO date comparison), and
* cross-type numeric coercion (e.g. string "7" vs number 5).
*/
greaterThan: (value: unknown, args?: Record<string, unknown>) => {
const other = args?.other;
if (value == null || other == null || value === "" || other === "")
return false;
if (typeof value === "number" && typeof other === "number")
return value > other;
if (typeof value === "string" && typeof other === "string")
return value > other;
const numVal = Number(value);
const numOther = Number(other);
if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther;
return false;
},
/**
* Required only when a condition is met.
* Uses JS truthiness: 0, false, "", null, and undefined are all
* treated as "condition not met" (field not required), matching
* the visibility system's bare-condition semantics.
*/
requiredIf: (value: unknown, args?: Record<string, unknown>) => {
const condition = args?.field;
if (!condition) return true;
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return true;
}, },
}; };
@@ -209,11 +273,12 @@ export function runValidationCheck(
): ValidationCheckResult { ): ValidationCheckResult {
const { value, stateModel, customFunctions } = ctx; const { value, stateModel, customFunctions } = ctx;
// Resolve args // Resolve args using resolvePropValue so nested $state refs (and any other
// prop expressions) are handled consistently with the rest of the system.
const resolvedArgs: Record<string, unknown> = {}; const resolvedArgs: Record<string, unknown> = {};
if (check.args) { if (check.args) {
for (const [key, argValue] of Object.entries(check.args)) { for (const [key, argValue] of Object.entries(check.args)) {
resolvedArgs[key] = resolveDynamicValue(argValue, stateModel); resolvedArgs[key] = resolvePropValue(argValue, { stateModel });
} }
} }
@@ -326,6 +391,11 @@ export const check = {
message, message,
}), }),
numeric: (message = "Must be a number"): ValidationCheck => ({
type: "numeric",
message,
}),
matches: ( matches: (
otherPath: string, otherPath: string,
message = "Fields must match", message = "Fields must match",
@@ -334,4 +404,34 @@ export const check = {
args: { other: { $state: otherPath } }, args: { other: { $state: otherPath } },
message, message,
}), }),
equalTo: (
otherPath: string,
message = "Fields must match",
): ValidationCheck => ({
type: "equalTo",
args: { other: { $state: otherPath } },
message,
}),
lessThan: (otherPath: string, message?: string): ValidationCheck => ({
type: "lessThan",
args: { other: { $state: otherPath } },
message: message ?? "Must be less than the compared field",
}),
greaterThan: (otherPath: string, message?: string): ValidationCheck => ({
type: "greaterThan",
args: { other: { $state: otherPath } },
message: message ?? "Must be greater than the compared field",
}),
requiredIf: (
fieldPath: string,
message = "This field is required",
): ValidationCheck => ({
type: "requiredIf",
args: { field: { $state: fieldPath } },
message,
}),
}; };
+31 -2
View File
@@ -17,6 +17,7 @@ import {
type ResolvedAction, type ResolvedAction,
} from "@json-render/core"; } from "@json-render/core";
import { useStateStore } from "./state"; import { useStateStore } from "./state";
import { useOptionalValidation } from "./validation";
/** /**
* Generate a unique ID for use with the "$id" token. * Generate a unique ID for use with the "$id" token.
@@ -137,6 +138,7 @@ export function ActionProvider({
children, children,
}: ActionProviderProps) { }: ActionProviderProps) {
const { get, set, getSnapshot } = useStateStore(); const { get, set, getSnapshot } = useStateStore();
const validation = useOptionalValidation();
const [handlers, setHandlers] = const [handlers, setHandlers] =
useState<Record<string, ActionHandler>>(initialHandlers); useState<Record<string, ActionHandler>>(initialHandlers);
@@ -227,13 +229,40 @@ export function ActionProvider({
if (previousScreen) { if (previousScreen) {
set("/currentScreen", previousScreen); set("/currentScreen", previousScreen);
} else { } else {
// Sentinel empty string = clear currentScreen (return to default)
set("/currentScreen", undefined); set("/currentScreen", undefined);
} }
} }
return; return;
} }
// Built-in: validateForm triggers validateAll from the ValidationProvider
// and writes the result to a state path (default: /formValidation).
// IMPORTANT: validateAll() is synchronous — it runs all registered field
// validations and returns immediately. This guarantees that the next action
// in a sequential list (e.g. [validateForm, submitForm]) can read the
// validation result from state without awaiting an extra tick.
if (resolved.action === "validateForm") {
const validateAll = validation?.validateAll;
if (!validateAll) {
console.warn(
"validateForm action was dispatched but no ValidationProvider is connected. " +
"Ensure ValidationProvider is rendered inside the provider tree.",
);
return;
}
const valid = validateAll();
const errors: Record<string, string[]> = {};
for (const [path, fs] of Object.entries(validation.fieldStates)) {
if (fs.result && !fs.result.valid) {
errors[path] = fs.result.errors;
}
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid, errors });
return;
}
const handler = handlers[resolved.action]; const handler = handlers[resolved.action];
if (!handler) { if (!handler) {
@@ -300,7 +329,7 @@ export function ActionProvider({
}); });
} }
}, },
[handlers, get, set, getSnapshot, navigate], [handlers, get, set, getSnapshot, navigate, validation],
); );
const confirm = useCallback(() => { const confirm = useCallback(() => {
+45 -28
View File
@@ -3,10 +3,10 @@
import React, { import React, {
createContext, createContext,
useContext, useContext,
useRef,
useState, useState,
useCallback, useCallback,
useMemo, useMemo,
useRef,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { import {
@@ -130,15 +130,14 @@ export function ValidationProvider({
customFunctions = {}, customFunctions = {},
children, children,
}: ValidationProviderProps) { }: ValidationProviderProps) {
const { state } = useStateStore(); const { state, getSnapshot } = useStateStore();
// Keep a ref to the latest state so `validate` doesn't change on every
// state update — preventing the entire validation context from churning.
const stateRef = useRef(state);
stateRef.current = state;
const [fieldStates, setFieldStates] = useState< const [fieldStates, setFieldStates] = useState<
Record<string, FieldValidationState> Record<string, FieldValidationState>
>({}); >({});
// Mutable mirror of fieldStates for synchronous reads (e.g. reading errors
// immediately after validateAll() before React flushes the batched setState).
const fieldStatesRef = useRef<Record<string, FieldValidationState>>({});
const [fieldConfigs, setFieldConfigs] = useState< const [fieldConfigs, setFieldConfigs] = useState<
Record<string, ValidationConfig> Record<string, ValidationConfig>
>({}); >({});
@@ -160,8 +159,10 @@ export function ValidationProvider({
const validate = useCallback( const validate = useCallback(
(path: string, config: ValidationConfig): ValidationResult => { (path: string, config: ValidationConfig): ValidationResult => {
// Walk the nested state object using JSON Pointer segments // Read from the store directly so validation sees values written in the
const currentState = stateRef.current; // same synchronous handler (e.g. setValue then validate in onChange).
// Using React state would return the stale pre-render snapshot.
const currentState = getSnapshot();
const segments = path.split("/").filter(Boolean); const segments = path.split("/").filter(Boolean);
let value: unknown = currentState; let value: unknown = currentState;
for (const seg of segments) { for (const seg of segments) {
@@ -178,37 +179,39 @@ export function ValidationProvider({
customFunctions, customFunctions,
}); });
setFieldStates((prev) => ({ const newFieldState: FieldValidationState = {
...prev, touched: fieldStatesRef.current[path]?.touched ?? true,
[path]: { validated: true,
touched: prev[path]?.touched ?? true, result,
validated: true, };
result, fieldStatesRef.current = {
}, ...fieldStatesRef.current,
})); [path]: newFieldState,
};
setFieldStates(fieldStatesRef.current);
return result; return result;
}, },
[customFunctions], [customFunctions, getSnapshot],
); );
const touch = useCallback((path: string) => { const touch = useCallback((path: string) => {
setFieldStates((prev) => ({ fieldStatesRef.current = {
...prev, ...fieldStatesRef.current,
[path]: { [path]: {
...prev[path], ...fieldStatesRef.current[path],
touched: true, touched: true,
validated: prev[path]?.validated ?? false, validated: fieldStatesRef.current[path]?.validated ?? false,
result: prev[path]?.result ?? null, result: fieldStatesRef.current[path]?.result ?? null,
}, },
})); };
setFieldStates(fieldStatesRef.current);
}, []); }, []);
const clear = useCallback((path: string) => { const clear = useCallback((path: string) => {
setFieldStates((prev) => { const { [path]: _, ...rest } = fieldStatesRef.current;
const { [path]: _, ...rest } = prev; fieldStatesRef.current = rest;
return rest; setFieldStates(rest);
});
}, []); }, []);
const validateAll = useCallback(() => { const validateAll = useCallback(() => {
@@ -227,7 +230,11 @@ export function ValidationProvider({
const value = useMemo<ValidationContextValue>( const value = useMemo<ValidationContextValue>(
() => ({ () => ({
customFunctions, customFunctions,
fieldStates, // Getter returns the mutable ref so callers that read fieldStates
// synchronously after validateAll() see the latest values.
get fieldStates() {
return fieldStatesRef.current;
},
validate, validate,
touch, touch,
clear, clear,
@@ -236,6 +243,8 @@ export function ValidationProvider({
}), }),
[ [
customFunctions, customFunctions,
// fieldStates (React state) stays in deps so the context value object
// is recreated on re-render, triggering downstream consumers.
fieldStates, fieldStates,
validate, validate,
touch, touch,
@@ -263,6 +272,14 @@ export function useValidation(): ValidationContextValue {
return ctx; return ctx;
} }
/**
* Non-throwing variant of useValidation.
* Returns null when no ValidationProvider is present.
*/
export function useOptionalValidation(): ValidationContextValue | null {
return useContext(ValidationContext);
}
/** /**
* Hook to get validation state for a field * Hook to get validation state for a field
*/ */
+773
View File
@@ -0,0 +1,773 @@
import { describe, it, expect, vi } from "vitest";
import React, { useState, useCallback, useMemo } from "react";
import { render, act, fireEvent, screen } from "@testing-library/react";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer";
import { useStateStore } from "./contexts/state";
import { useFieldValidation } from "./contexts/validation";
import { useBoundProp } from "./hooks";
// =============================================================================
// Stub components
// =============================================================================
function Button({ element, emit }: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => emit("press")}>
{element.props.label}
</button>
);
}
function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
const value = element.props.text;
return (
<span data-testid="text">
{value == null
? ""
: typeof value === "string"
? value
: JSON.stringify(value)}
</span>
);
}
function InputField({
element,
bindings,
}: ComponentRenderProps<{
label?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
}>) {
const props = element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState("");
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const config = useMemo(
() => (hasValidation ? { checks: props.checks ?? [] } : undefined),
[hasValidation, props.checks],
);
const { errors } = useFieldValidation(bindings?.value ?? "", config);
return (
<div>
{props.label && <label>{props.label}</label>}
<input
data-testid="input"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{errors.length > 0 && <span data-testid="input-error">{errors[0]}</span>}
</div>
);
}
function SelectField({
element,
bindings,
}: ComponentRenderProps<{ label?: string; value?: string }>) {
const props = element.props;
const [boundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
return <span data-testid="select-value">{boundValue ?? ""}</span>;
}
/**
* Select stub that mirrors the real shadcn Select validation behavior:
* calls setValue then validate() synchronously in onValueChange.
*/
function ValidatedSelect({
element,
bindings,
emit,
}: ComponentRenderProps<{
label?: string;
name?: string;
options?: string[];
placeholder?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
validateOn?: "change" | "blur" | "submit";
}>) {
const props = element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState("");
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.value && props.checks?.length);
const config = useMemo(
() =>
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
[hasValidation, props.checks, validateOn],
);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
config,
);
const options = props.options ?? [];
return (
<div>
{props.label && <label>{props.label}</label>}
<select
data-testid={`select-${props.name ?? "default"}`}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (hasValidation && validateOn === "change") validate();
emit("change");
}}
>
<option value="">{props.placeholder ?? "Select..."}</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{errors.length > 0 && (
<span data-testid={`select-error-${props.name ?? "default"}`}>
{errors[0]}
</span>
)}
</div>
);
}
function StateProbe() {
const { state } = useStateStore();
return <pre data-testid="state-probe">{JSON.stringify(state)}</pre>;
}
const registry = { Button, Text, Input: InputField, Select: SelectField };
function getState(): Record<string, unknown> {
return JSON.parse(screen.getByTestId("state-probe").textContent!);
}
// =============================================================================
// $computed expressions in rendering
// =============================================================================
describe("$computed expressions in rendering", () => {
it("resolves a $computed prop using provided functions", async () => {
const spec: Spec = {
state: { first: "Jane", last: "Doe" },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$computed: "fullName",
args: {
first: { $state: "/first" },
last: { $state: "/last" },
},
},
},
children: [],
},
},
};
const functions = {
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
};
render(
<JSONUIProvider
registry={registry}
initialState={spec.state}
functions={functions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
});
it("renders gracefully when functions prop is omitted", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $computed: "missing" },
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("");
warnSpy.mockRestore();
});
});
// =============================================================================
// $template expressions in rendering
// =============================================================================
describe("$template expressions in rendering", () => {
it("interpolates state values into a template string", () => {
const spec: Spec = {
state: { user: { name: "Alice" }, count: 3 },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$template: "Hello, ${/user/name}! You have ${/count} messages.",
},
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe(
"Hello, Alice! You have 3 messages.",
);
});
it("resolves missing paths to empty string", () => {
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $template: "Hi ${/name}!" },
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("Hi !");
});
});
// =============================================================================
// Watchers
// =============================================================================
describe("watchers (watch field)", () => {
it("does not fire on initial render, fires when watched state changes", async () => {
const loadCities = vi.fn(async (params: Record<string, unknown>) => {
// no-op, just tracking the call
});
const spec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
// Add watcher as a child of a wrapper so both render
const wrapperSpec: Spec = {
...spec,
root: "wrapper",
elements: {
...spec.elements,
wrapper: {
type: "Button",
props: { label: "wrapper" },
children: ["main", "watcher"],
},
},
};
// Use a Stack-like wrapper -- but since we only have Button/Text/Select
// stubs, we need a container. Let's add a simple Stack stub.
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => {
return <div data-testid="stack">{children}</div>;
};
const reg = { ...registry, Stack };
const stackSpec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
render(
<JSONUIProvider
registry={reg}
initialState={stackSpec.state}
handlers={{ loadCities }}
>
<Renderer spec={stackSpec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
// Not called on initial render
expect(loadCities).not.toHaveBeenCalled();
// Change the watched state path
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
expect(loadCities).toHaveBeenCalledTimes(1);
expect(loadCities).toHaveBeenCalledWith(
expect.objectContaining({ country: "US" }),
);
});
it("fires multiple action bindings on the same watch path", async () => {
const action1 = vi.fn();
const action2 = vi.fn();
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: [
{
action: "setState",
params: { statePath: "/value", value: "b" },
},
],
},
children: [],
},
watcher: {
type: "Text",
props: { text: { $state: "/value" } },
watch: {
"/value": [{ action: "action1" }, { action: "action2" }],
},
children: [],
},
},
};
render(
<JSONUIProvider
registry={reg}
initialState={spec.state}
handlers={{ action1, action2 }}
>
<Renderer spec={spec} registry={reg} />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
expect(action1).toHaveBeenCalledTimes(1);
expect(action2).toHaveBeenCalledTimes(1);
});
});
// =============================================================================
// validateForm action
// =============================================================================
describe("validateForm action", () => {
it("writes { valid: false } when a required field is empty", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
// Click submit with empty email
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
});
it("writes { valid: true } when all fields pass validation", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "test@example.com" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.result).toEqual({ valid: true, errors: {} });
});
it("defaults to /formValidation when no statePath is provided", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { name: "filled" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["nameInput", "submitBtn"],
},
nameInput: {
type: "Input",
props: {
label: "Name",
value: { $bindState: "/form/name" },
checks: [{ type: "required", message: "Required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [{ action: "validateForm" }],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.formValidation).toEqual({ valid: true, errors: {} });
});
});
// =============================================================================
// Select validate-on-change timing (#151)
// =============================================================================
describe("Select validate-on-change sees the new value, not the stale value", () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const regWithSelect = {
...registry,
Stack,
Select: ValidatedSelect,
};
it("does not show 'required' error when selecting the first value", async () => {
const spec: Spec = {
state: { form: { country: "" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["countrySelect"],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>,
);
// Select "US" for the first time (from empty)
await act(async () => {
fireEvent.change(screen.getByTestId("select-country"), {
target: { value: "US" },
});
});
// The value should be set in state
const state = getState();
expect((state.form as Record<string, unknown>).country).toBe("US");
// No validation error should appear -- "US" is non-empty
expect(screen.queryByTestId("select-error-country")).toBeNull();
});
it("does not show 'required' error when selecting the first city after country change resets it", async () => {
const spec: Spec = {
state: {
form: { country: "US", city: "" },
availableCities: ["New York", "Chicago"],
},
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["citySelect"],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: ["New York", "Chicago"],
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>,
);
// Select "New York" for the first time (from empty)
await act(async () => {
fireEvent.change(screen.getByTestId("select-city"), {
target: { value: "New York" },
});
});
const state = getState();
expect((state.form as Record<string, unknown>).city).toBe("New York");
// No validation error should appear
expect(screen.queryByTestId("select-error-city")).toBeNull();
});
});
+1
View File
@@ -30,6 +30,7 @@ export {
export { export {
ValidationProvider, ValidationProvider,
useValidation, useValidation,
useOptionalValidation,
useFieldValidation, useFieldValidation,
type ValidationContextValue, type ValidationContextValue,
type ValidationProviderProps, type ValidationProviderProps,
+137 -31
View File
@@ -5,7 +5,9 @@ import React, {
type ErrorInfo, type ErrorInfo,
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef,
} from "react"; } from "react";
import type { import type {
UIElement, UIElement,
@@ -14,6 +16,7 @@ import type {
Catalog, Catalog,
SchemaDefinition, SchemaDefinition,
StateStore, StateStore,
ComputedFunction,
} from "@json-render/core"; } from "@json-render/core";
import { import {
resolveElementProps, resolveElementProps,
@@ -136,6 +139,19 @@ class ElementErrorBoundary extends React.Component<
} }
} }
// ---------------------------------------------------------------------------
// FunctionsContext – provides $computed functions to the element tree
// ---------------------------------------------------------------------------
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
const FunctionsContext =
React.createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
function useFunctions(): Record<string, ComputedFunction> {
return React.useContext(FunctionsContext);
}
interface ElementRendererProps { interface ElementRendererProps {
element: UIElement; element: UIElement;
spec: Spec; spec: Spec;
@@ -158,21 +174,22 @@ const ElementRenderer = React.memo(function ElementRenderer({
const repeatScope = useRepeatScope(); const repeatScope = useRepeatScope();
const { ctx } = useVisibility(); const { ctx } = useVisibility();
const { execute } = useActions(); const { execute } = useActions();
const { getSnapshot } = useStateStore(); const { getSnapshot, state: watchState } = useStateStore();
const functions = useFunctions();
// Build context with repeat scope (used for both visibility and props) // Build context with repeat scope and $computed functions
const fullCtx: PropResolutionContext = useMemo( const fullCtx: PropResolutionContext = useMemo(() => {
() => const base: PropResolutionContext = repeatScope
repeatScope ? {
? { ...ctx,
...ctx, repeatItem: repeatScope.item,
repeatItem: repeatScope.item, repeatIndex: repeatScope.index,
repeatIndex: repeatScope.index, repeatBasePath: repeatScope.basePath,
repeatBasePath: repeatScope.basePath, }
} : { ...ctx };
: ctx, base.functions = functions;
[ctx, repeatScope], return base;
); }, [ctx, repeatScope, functions]);
// Evaluate visibility (now supports $item/$index inside repeat scopes) // Evaluate visibility (now supports $item/$index inside repeat scopes)
const isVisible = const isVisible =
@@ -227,6 +244,85 @@ const ElementRenderer = React.memo(function ElementRenderer({
[onBindings, emit], [onBindings, emit],
); );
// Watch effect: fire actions when watched state paths change.
// Must be called before any early return to satisfy Rules of Hooks.
//
// Two refs serve distinct roles:
// - `stableWatchRef` (useMemo): holds the last emitted values object so we
// can return the same reference when watched values haven't changed,
// preventing the downstream useEffect from firing on unrelated state updates.
// - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot
// for change detection. Starts as `null` to skip the initial mount.
const watchConfig = element.watch;
const prevWatchValues = useRef<Record<string, unknown> | null>(null);
const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
const watchedValues = useMemo(() => {
if (!watchConfig) return undefined;
const values: Record<string, unknown> = {};
for (const path of Object.keys(watchConfig)) {
values[path] = getByPath(watchState, path);
}
const prev = stableWatchRef.current;
if (prev) {
const keys = Object.keys(values);
if (
keys.length === Object.keys(prev).length &&
keys.every((k) => values[k] === prev[k])
) {
return prev;
}
}
stableWatchRef.current = values;
return values;
}, [watchConfig, watchState]);
useEffect(() => {
if (!watchConfig || !watchedValues) return;
const paths = Object.keys(watchConfig);
if (paths.length === 0) return;
const prev = prevWatchValues.current;
prevWatchValues.current = watchedValues;
// Skip the initial mount — only fire on changes
if (prev === null) return;
let cancelled = false;
void (async () => {
for (const path of paths) {
if (cancelled) break;
if (watchedValues[path] !== prev[path]) {
const binding = watchConfig[path];
if (!binding) continue;
const bindings = Array.isArray(binding) ? binding : [binding];
for (const b of bindings) {
if (cancelled) break;
if (!b.params) {
await execute(b);
if (cancelled) break;
continue;
}
const liveCtx: PropResolutionContext = {
...fullCtx,
stateModel: getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
if (cancelled) break;
}
}
}
})().catch(console.error);
return () => {
cancelled = true;
};
}, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]);
// Don't render if not visible // Don't render if not visible
if (!isVisible) { if (!isVisible) {
return null; return null;
@@ -419,6 +515,8 @@ export interface JSONUIProviderProps {
string, string,
(value: unknown, args?: Record<string, unknown>) => boolean (value: unknown, args?: Record<string, unknown>) => boolean
>; >;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Callback when state changes (uncontrolled mode) */ /** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
children: ReactNode; children: ReactNode;
@@ -434,6 +532,7 @@ export function JSONUIProvider({
handlers, handlers,
navigate, navigate,
validationFunctions, validationFunctions,
functions,
onStateChange, onStateChange,
children, children,
}: JSONUIProviderProps) { }: JSONUIProviderProps) {
@@ -444,12 +543,14 @@ export function JSONUIProvider({
onStateChange={onStateChange} onStateChange={onStateChange}
> >
<VisibilityProvider> <VisibilityProvider>
<ActionProvider handlers={handlers} navigate={navigate}> <ValidationProvider customFunctions={validationFunctions}>
<ValidationProvider customFunctions={validationFunctions}> <ActionProvider handlers={handlers} navigate={navigate}>
{children} <FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
<ConfirmationDialogManager /> {children}
</ValidationProvider> <ConfirmationDialogManager />
</ActionProvider> </FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
</VisibilityProvider> </VisibilityProvider>
</StateProvider> </StateProvider>
); );
@@ -650,6 +751,8 @@ export interface CreateRendererProps {
onAction?: (actionName: string, params?: Record<string, unknown>) => void; onAction?: (actionName: string, params?: Record<string, unknown>) => void;
/** Callback when state changes (uncontrolled mode) */ /** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Whether the spec is currently loading/streaming */ /** Whether the spec is currently loading/streaming */
loading?: boolean; loading?: boolean;
/** Fallback component for unknown types */ /** Fallback component for unknown types */
@@ -703,6 +806,7 @@ export function createRenderer<
state, state,
onAction, onAction,
onStateChange, onStateChange,
functions,
loading, loading,
fallback, fallback,
}: CreateRendererProps) { }: CreateRendererProps) {
@@ -730,17 +834,19 @@ export function createRenderer<
onStateChange={onStateChange} onStateChange={onStateChange}
> >
<VisibilityProvider> <VisibilityProvider>
<ActionProvider handlers={actionHandlers}> <ValidationProvider>
<ValidationProvider> <ActionProvider handlers={actionHandlers}>
<Renderer <FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
spec={spec} <Renderer
registry={registry} spec={spec}
loading={loading} registry={registry}
fallback={fallback} loading={loading}
/> fallback={fallback}
<ConfirmationDialogManager /> />
</ValidationProvider> <ConfirmationDialogManager />
</ActionProvider> </FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
</VisibilityProvider> </VisibilityProvider>
</StateProvider> </StateProvider>
); );
+5
View File
@@ -67,6 +67,11 @@ export const schema = defineSchema(
description: description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }", "Remove an item from an array in state by index. Params: { statePath: string, index: number }",
}, },
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
},
], ],
defaultRules: [ defaultRules: [
// Element integrity // Element integrity
+37 -33
View File
@@ -1,5 +1,21 @@
import { z } from "zod"; import { z } from "zod";
// =============================================================================
// Shared validation schemas used across form components
// =============================================================================
const validationCheckSchema = z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable();
const validateOnSchema = z.enum(["change", "blur", "submit"]).nullable();
// ============================================================================= // =============================================================================
// shadcn/ui Component Definitions // shadcn/ui Component Definitions
// ============================================================================= // =============================================================================
@@ -272,19 +288,12 @@ export const shadcnComponentDefinitions = {
type: z.enum(["text", "email", "password", "number"]).nullable(), type: z.enum(["text", "email", "password", "number"]).nullable(),
placeholder: z.string().nullable(), placeholder: z.string().nullable(),
value: z.string().nullable(), value: z.string().nullable(),
checks: z checks: validationCheckSchema,
.array( validateOn: validateOnSchema,
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}), }),
events: ["submit", "focus", "blur"], events: ["submit", "focus", "blur"],
description: description:
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).", "Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength). validateOn controls timing (default: blur).",
example: { example: {
label: "Email", label: "Email",
name: "email", name: "email",
@@ -300,18 +309,11 @@ export const shadcnComponentDefinitions = {
placeholder: z.string().nullable(), placeholder: z.string().nullable(),
rows: z.number().nullable(), rows: z.number().nullable(),
value: z.string().nullable(), value: z.string().nullable(),
checks: z checks: validationCheckSchema,
.array( validateOn: validateOnSchema,
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}), }),
description: description:
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.", "Multi-line text input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: blur).",
}, },
Select: { Select: {
@@ -321,19 +323,12 @@ export const shadcnComponentDefinitions = {
options: z.array(z.string()), options: z.array(z.string()),
placeholder: z.string().nullable(), placeholder: z.string().nullable(),
value: z.string().nullable(), value: z.string().nullable(),
checks: z checks: validationCheckSchema,
.array( validateOn: validateOnSchema,
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}), }),
events: ["change"], events: ["change"],
description: description:
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.", "Dropdown select input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
}, },
Checkbox: { Checkbox: {
@@ -341,9 +336,12 @@ export const shadcnComponentDefinitions = {
label: z.string(), label: z.string(),
name: z.string(), name: z.string(),
checked: z.boolean().nullable(), checked: z.boolean().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
}), }),
events: ["change"], events: ["change"],
description: "Checkbox input. Use { $bindState } on checked for binding.", description:
"Checkbox input. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
}, },
Radio: { Radio: {
@@ -352,9 +350,12 @@ export const shadcnComponentDefinitions = {
name: z.string(), name: z.string(),
options: z.array(z.string()), options: z.array(z.string()),
value: z.string().nullable(), value: z.string().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
}), }),
events: ["change"], events: ["change"],
description: "Radio button group. Use { $bindState } on value for binding.", description:
"Radio button group. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
}, },
Switch: { Switch: {
@@ -362,9 +363,12 @@ export const shadcnComponentDefinitions = {
label: z.string(), label: z.string(),
name: z.string(), name: z.string(),
checked: z.boolean().nullable(), checked: z.boolean().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
}), }),
events: ["change"], events: ["change"],
description: "Toggle switch. Use { $bindState } on checked for binding.", description:
"Toggle switch. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
}, },
Slider: { Slider: {
+79 -32
View File
@@ -664,11 +664,12 @@ export const shadcnComponents = {
const isBound = !!bindings?.value; const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue; const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue; const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "blur";
const hasValidation = !!(bindings?.value && props.checks?.length); const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation( const { errors, validate } = useFieldValidation(
bindings?.value ?? "", bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined, hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
); );
return ( return (
@@ -682,13 +683,16 @@ export const shadcnComponents = {
type={props.type ?? "text"} type={props.type ?? "text"}
placeholder={props.placeholder ?? ""} placeholder={props.placeholder ?? ""}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => {
setValue(e.target.value);
if (hasValidation && validateOn === "change") validate();
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") emit("submit"); if (e.key === "Enter") emit("submit");
}} }}
onFocus={() => emit("focus")} onFocus={() => emit("focus")}
onBlur={() => { onBlur={() => {
if (hasValidation) validate(); if (hasValidation && validateOn === "blur") validate();
emit("blur"); emit("blur");
}} }}
/> />
@@ -711,11 +715,12 @@ export const shadcnComponents = {
const isBound = !!bindings?.value; const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue; const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue; const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "blur";
const hasValidation = !!(bindings?.value && props.checks?.length); const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation( const { errors, validate } = useFieldValidation(
bindings?.value ?? "", bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined, hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
); );
return ( return (
@@ -729,9 +734,12 @@ export const shadcnComponents = {
placeholder={props.placeholder ?? ""} placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3} rows={props.rows ?? 3}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => {
setValue(e.target.value);
if (hasValidation && validateOn === "change") validate();
}}
onBlur={() => { onBlur={() => {
if (hasValidation) validate(); if (hasValidation && validateOn === "blur") validate();
}} }}
/> />
{errors.length > 0 && ( {errors.length > 0 && (
@@ -758,11 +766,12 @@ export const shadcnComponents = {
const options = rawOptions.map((opt) => const options = rawOptions.map((opt) =>
typeof opt === "string" ? opt : String(opt ?? ""), typeof opt === "string" ? opt : String(opt ?? ""),
); );
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.value && props.checks?.length); const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation( const { errors, validate } = useFieldValidation(
bindings?.value ?? "", bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined, hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
); );
return ( return (
@@ -772,7 +781,8 @@ export const shadcnComponents = {
value={value} value={value}
onValueChange={(v) => { onValueChange={(v) => {
setValue(v); setValue(v);
if (hasValidation) validate(); // Select has no native blur event, so only validate on "change"
if (hasValidation && validateOn === "change") validate();
emit("change"); emit("change");
}} }}
> >
@@ -808,19 +818,32 @@ export const shadcnComponents = {
const checked = isBound ? (boundChecked ?? false) : localChecked; const checked = isBound ? (boundChecked ?? false) : localChecked;
const setChecked = isBound ? setBoundChecked : setLocalChecked; const setChecked = isBound ? setBoundChecked : setLocalChecked;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.checked && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.checked ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
);
return ( return (
<div className="flex items-center space-x-2"> <div className="space-y-1">
<Checkbox <div className="flex items-center space-x-2">
id={props.name ?? undefined} <Checkbox
checked={checked} id={props.name ?? undefined}
onCheckedChange={(c) => { checked={checked}
setChecked(c === true); onCheckedChange={(c) => {
emit("change"); setChecked(c === true);
}} if (hasValidation && validateOn === "change") validate();
/> emit("change");
<Label htmlFor={props.name ?? undefined} className="cursor-pointer"> }}
{props.label} />
</Label> <Label htmlFor={props.name ?? undefined} className="cursor-pointer">
{props.label}
</Label>
</div>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
</div> </div>
); );
}, },
@@ -843,6 +866,13 @@ export const shadcnComponents = {
const value = isBound ? (boundValue ?? "") : localValue; const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue; const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{props.label && <Label>{props.label}</Label>} {props.label && <Label>{props.label}</Label>}
@@ -850,6 +880,7 @@ export const shadcnComponents = {
value={value} value={value}
onValueChange={(v) => { onValueChange={(v) => {
setValue(v); setValue(v);
if (hasValidation && validateOn === "change") validate();
emit("change"); emit("change");
}} }}
> >
@@ -868,6 +899,9 @@ export const shadcnComponents = {
</div> </div>
))} ))}
</RadioGroup> </RadioGroup>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
</div> </div>
); );
}, },
@@ -886,19 +920,32 @@ export const shadcnComponents = {
const checked = isBound ? (boundChecked ?? false) : localChecked; const checked = isBound ? (boundChecked ?? false) : localChecked;
const setChecked = isBound ? setBoundChecked : setLocalChecked; const setChecked = isBound ? setBoundChecked : setLocalChecked;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.checked && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.checked ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
);
return ( return (
<div className="flex items-center justify-between space-x-2"> <div className="space-y-1">
<Label htmlFor={props.name ?? undefined} className="cursor-pointer"> <div className="flex items-center justify-between space-x-2">
{props.label} <Label htmlFor={props.name ?? undefined} className="cursor-pointer">
</Label> {props.label}
<Switch </Label>
id={props.name ?? undefined} <Switch
checked={checked} id={props.name ?? undefined}
onCheckedChange={(c) => { checked={checked}
setChecked(c); onCheckedChange={(c) => {
emit("change"); setChecked(c);
}} if (hasValidation && validateOn === "change") validate();
/> emit("change");
}}
/>
</div>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
</div> </div>
); );
}, },
+496
View File
@@ -0,0 +1,496 @@
# @json-render/vue
Vue 3 renderer for json-render. Turn JSON specs into Vue components with data binding, visibility, and actions.
## Installation
```bash
npm install @json-render/vue @json-render/core zod
```
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
## Quick Start
### 1. Create a Catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
description: "A card container",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
description: "A clickable button",
},
Input: {
props: z.object({
value: z.union([z.string(), z.record(z.unknown())]).nullable(),
label: z.string(),
placeholder: z.string().nullable(),
}),
description: "Text input field with optional value binding",
},
},
actions: {
submit: { description: "Submit the form" },
cancel: { description: "Cancel and close" },
},
});
```
### 2. Define Component Implementations
Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element.
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
```typescript
import { h } from "vue";
import { defineRegistry } from "@json-render/vue";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [
h("h3", null, props.title),
props.description ? h("p", null, props.description) : null,
children,
]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
Input: ({ props, bindings }) => {
// Use bindings?.value with a watcher to implement two-way binding
return h("label", null, [
props.label,
h("input", {
placeholder: props.placeholder ?? "",
value: props.value ?? "",
}),
]);
},
},
// actions stubs are required when the catalog declares actions:
actions: {
submit: async () => {},
cancel: async () => {},
},
});
```
> **Tip:** Use `useBoundProp(props.value, bindings?.value)` for two-way binding, or handle the `bindings` object directly in your component.
### 3. Render Specs
```vue
<script setup lang="ts">
import { StateProvider, ActionProvider, Renderer } from "@json-render/vue";
import { registry } from "./registry";
const spec = { /* ... */ };
function handleSubmit(params) {
console.log("Submit", params);
}
</script>
<template>
<StateProvider :initial-state="{ form: { name: '' } }">
<ActionProvider :handlers="{ submit: handleSubmit }">
<Renderer :spec="spec" :registry="registry" />
</ActionProvider>
</StateProvider>
</template>
```
## Spec Format
The Vue renderer uses the same flat element map format as the React renderer:
```typescript
interface Spec {
root: string; // Key of the root element
elements: Record<string, UIElement>; // Flat map of elements by key
state?: Record<string, unknown>; // Optional initial state
}
interface UIElement {
type: string; // Component name from catalog
props: Record<string, unknown>; // Component props
children?: string[]; // Keys of child elements
visible?: VisibilityCondition; // Visibility condition
}
```
Example spec:
```json
{
"root": "card-1",
"elements": {
"card-1": {
"type": "Card",
"props": { "title": "Welcome" },
"children": ["input-1", "btn-1"]
},
"input-1": {
"type": "Input",
"props": {
"value": { "$bindState": "/form/name" },
"label": "Name",
"placeholder": "Enter name"
}
},
"btn-1": {
"type": "Button",
"props": { "label": "Submit" },
"children": []
}
}
}
```
## Providers
### StateProvider
Share data across components with JSON Pointer paths:
```vue
<StateProvider :initial-state="{ user: { name: 'John' } }">
<!-- children -->
</StateProvider>
```
```typescript
// In composables:
const { state, get, set } = useStateStore();
const name = get("/user/name"); // "John"
set("/user/age", 25);
// state is a ShallowRef<StateModel> — access with state.value
console.log(state.value);
```
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Pinia, VueUse, etc.):
```typescript
import { createStateStore, type StateStore } from "@json-render/vue";
// Option 1: Use the built-in store outside of Vue
const store = createStateStore({ count: 0 });
```
```vue
<StateProvider :store="store">
<!-- children -->
</StateProvider>
```
```typescript
// Mutate from anywhere — Vue will re-render automatically:
store.set("/count", 1);
// Option 2: Implement the StateStore interface with your own backend
const piniaStore: StateStore = {
get: (path) => getByPath(myStore.$state, path),
set: (path, value) => myStore.$patch(/* ... */),
update: (updates) => myStore.$patch(/* ... */),
getSnapshot: () => myStore.$state,
subscribe: (listener) => myStore.$subscribe(listener),
};
```
When `store` is provided, `initialState` and `onStateChange` are ignored.
### ActionProvider
Handle actions from components:
```vue
<ActionProvider :handlers="{ submit: handleSubmit, cancel: handleCancel }">
<!-- children -->
</ActionProvider>
```
### VisibilityProvider
Control element visibility based on data:
```vue
<VisibilityProvider>
<!-- children -->
</VisibilityProvider>
```
```json
{
"type": "Alert",
"props": { "message": "Error!" },
"visible": { "$state": "/form/hasError" }
}
```
### ValidationProvider
Add field validation:
```vue
<ValidationProvider>
<!-- children -->
</ValidationProvider>
```
```typescript
// Use validation composable:
const { errors, validate } = useFieldValidation("/form/email", {
checks: [
{ type: "required", message: "Email required" },
{ type: "email", message: "Invalid email" },
],
});
```
## Composables
| Composable | Purpose |
|------------|---------|
| `useStateStore()` | Access state context (`state` as `ShallowRef`, `get`, `set`, `update`) |
| `useStateValue(path)` | Get single value from state |
| `useStateBinding(path)` | Two-way data binding (deprecated — use `$bindState` instead) |
| `useIsVisible(condition)` | Check if a visibility condition is met |
| `useActions()` | Access action context |
| `useAction(binding)` | Get a single action dispatch function |
| `useFieldValidation(path, config)` | Field validation state |
> **Note:** `useStateStore().state` returns a `ShallowRef<StateModel>` — use `state.value` to access the underlying object. This differs from the React renderer where `state` is a plain object.
## Visibility Conditions
```typescript
// Truthiness check
{ "$state": "/user/isAdmin" }
// Auth state (use state path)
{ "$state": "/auth/isSignedIn" }
// Comparisons (flat style)
{ "$state": "/status", "eq": "active" }
{ "$state": "/count", "gt": 10 }
// Negation
{ "$state": "/maintenance", "not": true }
// Multiple conditions (implicit AND)
[
{ "$state": "/feature/enabled" },
{ "$state": "/maintenance", "not": true }
]
// Always / never
true // always visible
false // never visible
```
TypeScript helpers from `@json-render/core`:
```typescript
import { visibility } from "@json-render/core";
visibility.when("/path") // { $state: "/path" }
visibility.unless("/path") // { $state: "/path", not: true }
visibility.eq("/path", val) // { $state: "/path", eq: val }
visibility.neq("/path", val) // { $state: "/path", neq: val }
visibility.and(cond1, cond2) // { $and: [cond1, cond2] }
visibility.always // true
visibility.never // false
```
## Dynamic Prop Expressions
Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components.
```json
{
"type": "Badge",
"props": {
"label": { "$state": "/user/role" },
"color": {
"$cond": { "$state": "/user/role", "eq": "admin" },
"$then": "red",
"$else": "gray"
}
}
}
```
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop. Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop.
See [@json-render/core](../core/README.md) for full expression syntax.
## Built-in Actions
The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the Vue 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:
```json
{
"type": "Button",
"props": { "label": "Switch Tab" },
"on": {
"press": {
"action": "setState",
"params": { "statePath": "/activeTab", "value": "settings" }
}
},
"children": []
}
```
## Component Props
When using `defineRegistry`, components receive these props via their render function:
```typescript
import type { VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from the catalog (expressions resolved)
children?: VNode | VNode[]; // Rendered children (for container components)
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the parent is loading
bindings?: Record<string, string>; // State paths for $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault` or `bound`:
```typescript
Link: ({ props, on }) => {
const click = on("click");
return h("a", {
href: props.href,
onClick: (e: MouseEvent) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
},
}, props.label);
},
```
### `BaseComponentProps`
For building reusable component libraries that are not tied to a specific catalog, use the catalog-agnostic `BaseComponentProps` type:
```typescript
import type { BaseComponentProps } from "@json-render/vue";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
h("div", null, [props.title, children]);
```
## Generate AI Prompts
```typescript
const systemPrompt = catalog.prompt();
// Returns detailed prompt with component/action descriptions
```
## Full Example
```typescript
import { h } from "vue";
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { defineRegistry, Renderer, StateProvider } from "@json-render/vue";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
Greeting: {
props: z.object({ name: z.string() }),
description: "Displays a greeting",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Greeting: ({ props }) => h("h1", null, `Hello, ${props.name}!`),
},
});
const spec = {
root: "greeting-1",
elements: {
"greeting-1": {
type: "Greeting",
props: { name: "World" },
children: [],
},
},
};
// In your App.vue:
// <StateProvider>
// <Renderer :spec="spec" :registry="registry" />
// </StateProvider>
```
## Key Exports
| Export | Purpose |
|--------|---------|
| `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`) |
| `useStateStore` | Access state context (`state` is `ShallowRef<StateModel>`) |
| `useStateValue` | Get single value from state |
| `useActions` | Access actions context |
| `useAction` | Get a single action dispatch function |
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
### Types
| Export | Purpose |
|--------|---------|
| `ComponentContext` | Typed component render function context (catalog-aware) |
| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries |
| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` |
| `ActionProviderProps` | Props for `ActionProvider` |
| `ValidationProviderProps` | Props for `ValidationProvider` |
| `ComponentFn` | Component render function type |
| `SetState` | State setter type |
| `StateModel` | State model type |
| `StateStore` | Interface for plugging in external state management |
## Differences from `@json-render/react`
| API | React | Vue | Note |
|-----|-------|-----|------|
| `useStateStore().state` | `StateModel` | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@json-render/vue",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
"keywords": [
"json",
"ui",
"vue",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"components"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/vue"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.mjs",
"require": "./dist/schema.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@vue/test-utils": "^2.4.6",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"vue": "^3.5.0"
},
"peerDependencies": {
"vue": "^3.5.0",
"zod": "^4.0.0"
}
}
+152
View File
@@ -0,0 +1,152 @@
import type { VNode } from "vue";
import type {
Catalog,
InferCatalogComponents,
InferCatalogActions,
InferComponentProps,
InferActionParams,
StateModel,
} from "@json-render/core";
export type { StateModel };
// =============================================================================
// State Types
// =============================================================================
/**
* State setter function for updating application state
*/
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
// =============================================================================
// Component Types
// =============================================================================
/**
* Handle returned by the `on()` function for a specific event.
* Provides metadata about the event binding and a method to fire it.
*
* @example
* ```ts
* const press = on("press");
* if (press.shouldPreventDefault) e.preventDefault();
* press.emit();
* ```
*/
export interface EventHandle {
/** Fire the event (resolve action bindings) */
emit: () => void;
/** Whether any binding requested preventDefault */
shouldPreventDefault: boolean;
/** Whether any handler is bound to this event */
bound: boolean;
}
/**
* Catalog-agnostic base type for component render function arguments.
* Use this when building reusable component libraries that are not tied to a specific catalog.
*
* @example
* ```ts
* const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
* h('div', null, [props.title, children])
* ```
*/
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
/** Rendered children (from the default slot) */
children?: VNode | VNode[];
/** Simple event emitter (shorthand). Fires the event and returns void. */
emit: (event: string) => void;
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
on: (event: string) => EventHandle;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
loading?: boolean;
}
/**
* Context passed to component render functions
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => {
* return h('button', { onClick: () => ctx.emit("press") }, ctx.props.label)
* }
*/
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> extends BaseComponentProps<InferComponentProps<C, K>> {}
/**
* Component render function type for Vue
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = ({ props, emit }) =>
* h('button', { onClick: () => emit("press") }, props.label)
*/
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => VNode | VNode[] | null | string;
/**
* Registry of all component render functions for a catalog
* @example
* const components: Components<typeof myCatalog> = {
* Button: ({ props }) => h('button', null, props.label),
* Input: ({ props }) => h('input', { placeholder: props.placeholder }),
* };
*/
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
// =============================================================================
// Action Types
// =============================================================================
/**
* Action handler function type
* @example
* const viewCustomers: ActionFn<typeof catalog, 'viewCustomers'> = async (params, setState) => {
* const data = await fetch('/api/customers');
* setState(prev => ({ ...prev, customers: data }));
* };
*/
export type ActionFn<
C extends Catalog,
K extends keyof InferCatalogActions<C>,
> = (
params: InferActionParams<C, K> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
/**
* Registry of all action handlers for a catalog
* @example
* const actions: Actions<typeof myCatalog> = {
* viewCustomers: async (params, setState) => { ... },
* createCustomer: async (params, setState) => { ... },
* };
*/
export type Actions<C extends Catalog> = {
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
};
/**
* True when the catalog declares at least one action, false otherwise.
* Used by defineRegistry to conditionally require the `actions` field.
*/
export type CatalogHasActions<C extends Catalog> = [
InferCatalogActions<C>,
] extends [never]
? false
: [keyof InferCatalogActions<C>] extends [never]
? false
: true;
@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import { StateProvider, useStateStore } from "./state";
import { ActionProvider, useActions, useAction } from "./actions";
/** Mount StateProvider → ActionProvider with a child that captures context. */
function withProviders<T>(
composable: () => T,
handlers: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {},
initialState: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState } as any,
slots: {
default: () =>
h(ActionProvider as Component, { handlers } as any, {
default: () => h(Child),
}),
},
});
return { result };
}
describe("ActionProvider — provide/inject", () => {
it("useActions() throws outside a provider", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => useActions()).toThrow(
"useActions must be used within an ActionProvider",
);
warn.mockRestore();
});
});
describe("ActionProvider — custom handler dispatch", () => {
it("calling execute invokes the registered handler", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const { result } = withProviders(() => useActions(), { myAction: handler });
await result.execute({ action: "myAction", params: { x: 1 } });
expect(handler).toHaveBeenCalledOnce();
});
it("handler receives the resolved params", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const { result } = withProviders(() => useActions(), { myAction: handler });
await result.execute({ action: "myAction", params: { x: 1, y: "hello" } });
expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
});
it("console.warn is called for unknown actions", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { result } = withProviders(() => useActions());
await result.execute({ action: "unknownAction" });
expect(warn).toHaveBeenCalledWith(expect.stringContaining("unknownAction"));
warn.mockRestore();
});
});
describe("ActionProvider — built-in setState integration", () => {
it("executing setState updates state via the provider chain", async () => {
let stateCtx!: ReturnType<typeof useStateStore>;
let actionsCtx!: ReturnType<typeof useActions>;
const Child = defineComponent({
setup() {
stateCtx = useStateStore();
actionsCtx = useActions();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { v: 0 } } as any,
slots: {
default: () =>
h(ActionProvider as Component, null, {
default: () => h(Child),
}),
},
});
await actionsCtx.execute({
action: "setState",
params: { statePath: "/v", value: 42 },
});
expect(stateCtx.state.value).toEqual({ v: 42 });
});
});
describe("useAction", () => {
it("returns { execute, isLoading: false } before execution", () => {
const { result } = withProviders(() => useAction({ action: "myAction" }));
expect(typeof result.execute).toBe("function");
expect(result.isLoading.value).toBe(false);
});
});
+487
View File
@@ -0,0 +1,487 @@
import {
computed,
defineComponent,
h,
inject,
provide,
ref,
watch,
type ComputedRef,
type PropType,
} from "vue";
import {
resolveAction,
executeAction,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useOptionalValidation } from "./validation";
/**
* Generate a unique ID for use with the "$id" token.
*/
let idCounter = 0;
function generateUniqueId(): string {
idCounter += 1;
return `${Date.now()}-${idCounter}`;
}
/**
* Deep-resolve dynamic value references within an object.
*
* Supported tokens:
* - `{ $state: "/statePath" }` - read a value from state
* - `"$id"` (string) or `{ "$id": true }` - generate a unique ID
*/
function deepResolveValue(
value: unknown,
get: (path: string) => unknown,
): unknown {
if (value === null || value === undefined) return value;
if (value === "$id") {
return generateUniqueId();
}
if (typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 1 && typeof obj.$state === "string") {
return get(obj.$state as string);
}
if (keys.length === 1 && "$id" in obj) {
return generateUniqueId();
}
}
if (Array.isArray(value)) {
return value.map((item) => deepResolveValue(item, get));
}
if (typeof value === "object") {
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
resolved[key] = deepResolveValue(val, get);
}
return resolved;
}
return value;
}
/**
* Pending confirmation state
*/
export interface PendingConfirmation {
action: ResolvedAction;
handler: ActionHandler;
resolve: () => void;
reject: () => void;
}
/**
* Action context value
*/
export interface ActionContextValue {
handlers: Record<string, ActionHandler>;
loadingActions: Set<string>;
pendingConfirmation: PendingConfirmation | null;
execute: (binding: ActionBinding) => Promise<void>;
confirm: () => void;
cancel: () => void;
registerHandler: (name: string, handler: ActionHandler) => void;
}
const ACTIONS_KEY = Symbol("json-render:actions");
export interface ActionProviderProps {
handlers?: Record<string, ActionHandler>;
navigate?: (path: string) => void;
}
/**
* Provider for action execution
*/
export const ActionProvider = defineComponent({
name: "ActionProvider",
props: {
handlers: {
type: Object as PropType<Record<string, ActionHandler>>,
default: () => ({}),
},
navigate: {
type: Function as PropType<(path: string) => void>,
default: undefined,
},
},
setup(props, { slots }) {
const { get, set, getSnapshot } = useStateStore();
const validation = useOptionalValidation();
const handlers = ref<Record<string, ActionHandler>>(props.handlers ?? {});
const loadingActions = ref<Set<string>>(new Set());
const pendingConfirmation = ref<PendingConfirmation | null>(null);
// Sync handlers when prop changes
watch(
() => props.handlers,
(newHandlers) => {
if (newHandlers) handlers.value = newHandlers;
},
);
const registerHandler = (name: string, handler: ActionHandler) => {
handlers.value = { ...handlers.value, [name]: handler };
};
const execute = async (binding: ActionBinding): Promise<void> => {
const resolved = resolveAction(binding, getSnapshot());
// Built-in: setState
if (resolved.action === "setState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const value = resolved.params.value;
if (statePath) {
set(statePath, value);
}
return;
}
// Built-in: pushState
if (resolved.action === "pushState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const rawValue = resolved.params.value;
if (statePath) {
const resolvedValue = deepResolveValue(rawValue, get);
const arr = (get(statePath) as unknown[] | undefined) ?? [];
set(statePath, [...arr, resolvedValue]);
const clearStatePath = resolved.params.clearStatePath as
| string
| undefined;
if (clearStatePath) {
set(clearStatePath, "");
}
}
return;
}
// Built-in: removeState
if (resolved.action === "removeState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const index = resolved.params.index as number;
if (statePath !== undefined && index !== undefined) {
const arr = (get(statePath) as unknown[] | undefined) ?? [];
set(
statePath,
arr.filter((_, i) => i !== index),
);
}
return;
}
// Built-in: validateForm — triggers validateAll and writes result to state
if (resolved.action === "validateForm") {
const validateAll = validation?.validateAll;
if (!validateAll) {
console.warn(
"validateForm action was dispatched but no ValidationProvider is connected. " +
"Ensure ValidationProvider is rendered inside the provider tree.",
);
return;
}
const valid = validateAll();
const errors: Record<string, string[]> = {};
for (const [path, fs] of Object.entries(validation.fieldStates)) {
if (fs.result && !fs.result.valid) {
errors[path] = fs.result.errors;
}
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid, errors });
return;
}
// Built-in: push (navigation)
if (resolved.action === "push" && resolved.params) {
const screen = resolved.params.screen as string;
if (screen) {
const currentScreen = get("/currentScreen") as string | undefined;
const navStack = (get("/navStack") as string[] | undefined) ?? [];
if (currentScreen) {
set("/navStack", [...navStack, currentScreen]);
} else {
set("/navStack", [...navStack, ""]);
}
set("/currentScreen", screen);
}
return;
}
// Built-in: pop (navigation)
if (resolved.action === "pop") {
const navStack = (get("/navStack") as string[] | undefined) ?? [];
if (navStack.length > 0) {
const previousScreen = navStack[navStack.length - 1];
set("/navStack", navStack.slice(0, -1));
if (previousScreen) {
set("/currentScreen", previousScreen);
} else {
set("/currentScreen", undefined);
}
}
return;
}
const handler = handlers.value[resolved.action];
if (!handler) {
console.warn(`No handler registered for action: ${resolved.action}`);
return;
}
// If confirmation is required, show dialog first
if (resolved.confirm) {
await new Promise<void>((resolve, reject) => {
pendingConfirmation.value = {
action: resolved,
handler,
resolve: () => {
pendingConfirmation.value = null;
resolve();
},
reject: () => {
pendingConfirmation.value = null;
reject(new Error("Action cancelled"));
},
};
});
const addLoading = new Set(loadingActions.value);
addLoading.add(resolved.action);
loadingActions.value = addLoading;
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate: props.navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
const removeLoading = new Set(loadingActions.value);
removeLoading.delete(resolved.action);
loadingActions.value = removeLoading;
}
return;
}
// Execute immediately
const addLoading = new Set(loadingActions.value);
addLoading.add(resolved.action);
loadingActions.value = addLoading;
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate: props.navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
const removeLoading = new Set(loadingActions.value);
removeLoading.delete(resolved.action);
loadingActions.value = removeLoading;
}
};
const confirm = () => pendingConfirmation.value?.resolve();
const cancel = () => pendingConfirmation.value?.reject();
provide<ActionContextValue>(ACTIONS_KEY, {
get handlers() {
return handlers.value;
},
get loadingActions() {
return loadingActions.value;
},
get pendingConfirmation() {
return pendingConfirmation.value;
},
execute,
confirm,
cancel,
registerHandler,
});
return () => slots.default?.();
},
});
/**
* Composable to access action context
*/
export function useActions(): ActionContextValue {
const ctx = inject<ActionContextValue>(ACTIONS_KEY);
if (!ctx) {
throw new Error("useActions must be used within an ActionProvider");
}
return ctx;
}
/**
* Composable to execute an action binding
*/
export function useAction(binding: ActionBinding): {
execute: () => Promise<void>;
isLoading: ComputedRef<boolean>;
} {
const ctx = useActions();
return {
execute: () => ctx.execute(binding),
isLoading: computed(() => ctx.loadingActions.has(binding.action)),
};
}
// =============================================================================
// ConfirmDialog component
// =============================================================================
/**
* Props for ConfirmDialog component
*/
export interface ConfirmDialogProps {
confirm: ActionConfirm;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Default confirmation dialog component
*/
export const ConfirmDialog = defineComponent({
name: "ConfirmDialog",
props: {
confirm: {
type: Object as PropType<ActionConfirm>,
required: true,
},
onConfirm: {
type: Function as PropType<() => void>,
required: true,
},
onCancel: {
type: Function as PropType<() => void>,
required: true,
},
},
setup(props) {
return () => {
const isDanger = props.confirm.variant === "danger";
return h(
"div",
{
style: {
position: "fixed",
inset: "0",
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: "50",
},
onClick: props.onCancel,
},
[
h(
"div",
{
style: {
backgroundColor: "white",
borderRadius: "8px",
padding: "24px",
maxWidth: "400px",
width: "100%",
boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1)",
},
onClick: (e: MouseEvent) => e.stopPropagation(),
},
[
h(
"h3",
{
style: {
margin: "0 0 8px 0",
fontSize: "18px",
fontWeight: "600",
},
},
props.confirm.title,
),
h(
"p",
{
style: { margin: "0 0 24px 0", color: "#6b7280" },
},
props.confirm.message,
),
h(
"div",
{
style: {
display: "flex",
gap: "12px",
justifyContent: "flex-end",
},
},
[
h(
"button",
{
style: {
padding: "8px 16px",
borderRadius: "6px",
border: "1px solid #d1d5db",
backgroundColor: "white",
cursor: "pointer",
},
onClick: props.onCancel,
},
props.confirm.cancelLabel ?? "Cancel",
),
h(
"button",
{
style: {
padding: "8px 16px",
borderRadius: "6px",
border: "none",
backgroundColor: isDanger ? "#dc2626" : "#3b82f6",
color: "white",
cursor: "pointer",
},
onClick: props.onConfirm,
},
props.confirm.confirmLabel ?? "Confirm",
),
],
),
],
),
],
);
};
},
});
@@ -0,0 +1,51 @@
import { defineComponent, inject, provide, type PropType } from "vue";
/**
* Repeat scope value provided to child elements inside a repeated element.
*/
export interface RepeatScopeValue {
/** The current array item object */
item: unknown;
/** Index of the current item in the array */
index: number;
/** Absolute state path to the current array item (e.g. "/todos/0") — used for statePath two-way binding */
basePath: string;
}
const REPEAT_SCOPE_KEY = Symbol("json-render:repeat-scope");
/**
* Provides repeat scope to child elements so $item and $index expressions resolve correctly.
*/
export const RepeatScopeProvider = defineComponent({
name: "RepeatScopeProvider",
props: {
item: {
required: true,
},
index: {
type: Number,
required: true,
},
basePath: {
type: String as PropType<string>,
required: true,
},
},
setup(props, { slots }) {
provide(REPEAT_SCOPE_KEY, props as RepeatScopeValue);
return () => slots.default?.();
},
});
/**
* Read the current repeat scope (or null if not inside a repeated element).
*/
export function useRepeatScope(): RepeatScopeValue | null {
return (
inject<RepeatScopeValue>(
REPEAT_SCOPE_KEY,
null as unknown as RepeatScopeValue,
) ?? null
);
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import { createStateStore } from "@json-render/core";
import { StateProvider, useStateStore } from "./state";
/** Mount a StateProvider with a child that captures the injected context. */
function withProvider<T>(
composable: () => T,
props: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: props as any,
slots: { default: () => h(Child) },
});
return { result };
}
describe("StateProvider — provide/inject", () => {
it("useStateStore() throws outside a provider", () => {
// inject() returns undefined outside of component setup; our guard throws
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => useStateStore()).toThrow(
"useStateStore must be used within a StateProvider",
);
warn.mockRestore();
});
it("child receives the context from StateProvider", () => {
const { result } = withProvider(() => useStateStore());
expect(result).toBeDefined();
expect(result.state).toBeDefined();
expect(typeof result.get).toBe("function");
expect(typeof result.set).toBe("function");
expect(typeof result.update).toBe("function");
});
it("state.value is a plain object (not a wrapper type)", () => {
const { result } = withProvider(() => useStateStore(), {
initialState: { x: 1 },
});
expect(result.state.value).toEqual({ x: 1 });
// Should be a plain object, not a Vue Proxy of a ShallowRef wrapper
expect(typeof result.state.value).toBe("object");
});
});
describe("StateProvider (uncontrolled) — reactivity", () => {
it("state.value reflects initialState on mount", () => {
const { result } = withProvider(() => useStateStore(), {
initialState: { count: 5 },
});
expect(result.state.value).toEqual({ count: 5 });
});
it("after set(), state.value is updated synchronously", () => {
const { result } = withProvider(() => useStateStore(), {
initialState: { x: 0 },
});
result.set("/x", 42);
expect(result.state.value).toEqual({ x: 42 });
});
it("after update(), all paths are reflected in state.value", () => {
const { result } = withProvider(() => useStateStore(), {
initialState: {},
});
result.update({ "/a": 1, "/b": "hello" });
expect(result.state.value).toEqual({ a: 1, b: "hello" });
});
it("onStateChange is fired with the changes array on set", () => {
const onChange = vi.fn();
const { result } = withProvider(() => useStateStore(), {
initialState: {},
onStateChange: onChange,
});
result.set("/name", "Alice");
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith([{ path: "/name", value: "Alice" }]);
});
it("onStateChange is fired once with all changed entries on update", () => {
const onChange = vi.fn();
const { result } = withProvider(() => useStateStore(), {
initialState: {},
onStateChange: onChange,
});
result.update({ "/a": 1, "/b": 2 });
expect(onChange).toHaveBeenCalledOnce();
const [changes] = onChange.mock.calls[0]!;
expect(changes).toEqual(
expect.arrayContaining([
{ path: "/a", value: 1 },
{ path: "/b", value: 2 },
]),
);
});
});
describe("StateProvider (controlled mode)", () => {
it("state.value reads from the external store snapshot", () => {
const store = createStateStore({ x: 10 });
const { result } = withProvider(() => useStateStore(), { store });
expect(result.state.value).toEqual({ x: 10 });
});
it("set() writes through to the external store", () => {
const store = createStateStore({ x: 0 });
const { result } = withProvider(() => useStateStore(), { store });
result.set("/x", 99);
expect(store.getSnapshot()).toEqual({ x: 99 });
});
it("external store mutation triggers state.value update", () => {
const store = createStateStore({ x: 0 });
const { result } = withProvider(() => useStateStore(), { store });
store.set("/x", 99);
expect(result.state.value).toEqual({ x: 99 });
});
it("onStateChange is NOT called in controlled mode", () => {
const store = createStateStore({ x: 0 });
const onChange = vi.fn();
const { result } = withProvider(() => useStateStore(), {
store,
onStateChange: onChange,
});
result.set("/x", 99);
expect(onChange).not.toHaveBeenCalled();
});
});
+216
View File
@@ -0,0 +1,216 @@
import {
computed,
defineComponent,
inject,
onUnmounted,
provide,
ref,
shallowRef,
watch,
type ComputedRef,
type PropType,
type ShallowRef,
} from "vue";
import {
createStateStore,
getByPath,
type StateModel,
type StateStore,
} from "@json-render/core";
import { flattenToPointers } from "@json-render/core/store-utils";
/**
* State context value
*/
export interface StateContextValue {
/** Reactive state snapshot — use state.value in reactive contexts */
state: ShallowRef<StateModel>;
/** Get a value by path */
get: (path: string) => unknown;
/** Set a value by path */
set: (path: string, value: unknown) => void;
/** Update multiple values at once */
update: (updates: Record<string, unknown>) => void;
/** Return the live state snapshot from the underlying store (not through Vue reactivity). */
getSnapshot: () => StateModel;
}
const STATE_KEY = Symbol("json-render:state");
/**
* Props for StateProvider
*/
export interface StateProviderProps {
/**
* External store that owns the state. When provided, the provider operates
* in **controlled mode** — `initialState` and `onStateChange` are ignored
* and the store is the single source of truth.
*/
store?: StateStore;
/** Initial state model (used only in uncontrolled mode) */
initialState?: StateModel;
/**
* Callback when state changes (used only in uncontrolled mode).
* Called once per `set` or `update` with all changed entries.
*/
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
/**
* Provider for state model context.
*
* Supports two modes:
* - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand).
* - **Uncontrolled** (default): omit `store` and optionally pass
* `initialState` / `onStateChange`.
*/
export const StateProvider = defineComponent({
name: "StateProvider",
props: {
store: {
type: Object as PropType<StateStore>,
default: undefined,
},
initialState: {
type: Object as PropType<StateModel>,
default: undefined,
},
onStateChange: {
type: Function as PropType<
(changes: Array<{ path: string; value: unknown }>) => void
>,
default: undefined,
},
},
setup(props, { slots }) {
const isControlled = !!props.store;
// Use external store (controlled) or create internal store (uncontrolled)
const internalStore = !isControlled
? createStateStore(props.initialState ?? {})
: null;
const store: StateStore = props.store ?? internalStore!;
const state = shallowRef<StateModel>(store.getSnapshot());
const unsubscribe = store.subscribe(() => {
state.value = store.getSnapshot();
});
onUnmounted(unsubscribe);
// Sync external initialState changes (uncontrolled mode only)
if (!isControlled) {
let prevFlat: Record<string, unknown> =
props.initialState && Object.keys(props.initialState).length > 0
? flattenToPointers(props.initialState)
: {};
watch(
() => props.initialState,
(newInitialState) => {
if (!newInitialState) return;
const nextFlat =
Object.keys(newInitialState).length > 0
? flattenToPointers(newInitialState)
: {};
const allKeys = new Set([
...Object.keys(prevFlat),
...Object.keys(nextFlat),
]);
const updates: Record<string, unknown> = {};
for (const key of allKeys) {
if (prevFlat[key] !== nextFlat[key]) {
updates[key] = key in nextFlat ? nextFlat[key] : undefined;
}
}
prevFlat = nextFlat;
if (Object.keys(updates).length > 0) {
store.update(updates);
}
},
);
}
// Keep onStateChange in a ref so it always reads the latest callback
const onStateChangeRef = ref(props.onStateChange);
watch(
() => props.onStateChange,
(fn) => {
onStateChangeRef.value = fn;
},
);
const get = (path: string) => store.get(path);
const getSnapshot = () => store.getSnapshot();
const set = (path: string, value: unknown) => {
const prev = store.getSnapshot();
store.set(path, value);
if (!isControlled && store.getSnapshot() !== prev) {
onStateChangeRef.value?.([{ path, value }]);
}
};
const update = (updates: Record<string, unknown>) => {
const prev = store.getSnapshot();
store.update(updates);
if (!isControlled && store.getSnapshot() !== prev) {
const changes: Array<{ path: string; value: unknown }> = [];
for (const [path, value] of Object.entries(updates)) {
if (getByPath(prev, path) !== value) {
changes.push({ path, value });
}
}
if (changes.length > 0) {
onStateChangeRef.value?.(changes);
}
}
};
provide<StateContextValue>(STATE_KEY, {
state,
get,
set,
update,
getSnapshot,
});
return () => slots.default?.();
},
});
/**
* Composable to access the state context
*/
export function useStateStore(): StateContextValue {
const ctx = inject<StateContextValue>(STATE_KEY);
if (!ctx) {
throw new Error("useStateStore must be used within a StateProvider");
}
return ctx;
}
/**
* Composable to get a value from the state model (reactive)
*/
export function useStateValue<T>(path: string): ComputedRef<T | undefined> {
const { state } = useStateStore();
return computed(() => getByPath(state.value, path) as T | undefined);
}
/**
* Composable to get and set a value from the state model by path.
*
* This is the path-based variant for use in arbitrary composables. For
* registry components that receive `bindings` from the renderer, prefer
* `useBoundProp` which reads the already-resolved prop value and writes back
* to the bound path.
*/
export function useStateBinding<T>(
path: string,
): [ComputedRef<T | undefined>, (value: T) => void] {
const { state, set } = useStateStore();
const value = computed(() => getByPath(state.value, path) as T | undefined);
const setValue = (newValue: T) => set(path, newValue);
return [value, setValue];
}
@@ -0,0 +1,209 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import { StateProvider } from "./state";
import {
ValidationProvider,
useOptionalValidation,
useValidation,
useFieldValidation,
} from "./validation";
/** Mount StateProvider → ValidationProvider with a child that captures context. */
function withProviders<T>(
composable: () => T,
initialState: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
return { result };
}
/** Mount only inside StateProvider (no ValidationProvider) */
function withStateOnly<T>(composable: () => T): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: { default: () => h(Child) },
});
return { result };
}
describe("ValidationProvider — provide/inject", () => {
it("useValidation() throws outside a provider", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => useValidation()).toThrow(
"useValidation must be used within a ValidationProvider",
);
warn.mockRestore();
});
});
describe("useOptionalValidation", () => {
it("returns null outside a ValidationProvider", () => {
const { result } = withStateOnly(() => useOptionalValidation());
expect(result).toBeNull();
});
it("returns context inside a ValidationProvider", () => {
const { result } = withProviders(() => useOptionalValidation());
expect(result).not.toBeNull();
expect(typeof result!.validate).toBe("function");
expect(typeof result!.validateAll).toBe("function");
});
});
describe("useFieldValidation — lifecycle", () => {
it("after validate(), isValid and errors reflect the result", () => {
const { result } = withProviders(
() =>
useFieldValidation("/name", {
checks: [{ type: "required", message: "Name is required" }],
}),
{ name: "" },
);
const validationResult = result.validate();
expect(validationResult.valid).toBe(false);
expect(validationResult.errors).toContain("Name is required");
});
it("after validate() with valid value, isValid is true", () => {
const { result } = withProviders(
() =>
useFieldValidation("/name", {
checks: [{ type: "required", message: "Name is required" }],
}),
{ name: "Alice" },
);
const validationResult = result.validate();
expect(validationResult.valid).toBe(true);
expect(validationResult.errors).toHaveLength(0);
});
it("touch() sets touched: true in the validation context", () => {
let validationCtx!: ReturnType<typeof useValidation>;
let fieldCtx!: ReturnType<typeof useFieldValidation>;
const Child = defineComponent({
setup() {
validationCtx = useValidation();
fieldCtx = useFieldValidation("/email");
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
fieldCtx.touch();
expect(validationCtx.fieldStates["/email"]?.touched).toBe(true);
});
it("clear() resets the field state from the validation context", () => {
let validationCtx!: ReturnType<typeof useValidation>;
let fieldCtx!: ReturnType<typeof useFieldValidation>;
const Child = defineComponent({
setup() {
validationCtx = useValidation();
fieldCtx = useFieldValidation("/email", {
checks: [{ type: "required", message: "Required" }],
});
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { email: "" } } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
fieldCtx.validate(); // populate fieldStates
fieldCtx.clear();
expect(validationCtx.fieldStates["/email"]).toBeUndefined();
});
it("validateAll() returns true when all registered fields pass", () => {
let validationCtx!: ReturnType<typeof useValidation>;
const Child = defineComponent({
setup() {
validationCtx = useValidation();
useFieldValidation("/name", {
checks: [{ type: "required", message: "Required" }],
});
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { name: "Alice" } } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
expect(validationCtx.validateAll()).toBe(true);
});
it("validateAll() returns false when any field fails", () => {
let validationCtx!: ReturnType<typeof useValidation>;
const Child = defineComponent({
setup() {
validationCtx = useValidation();
useFieldValidation("/name", {
checks: [{ type: "required", message: "Required" }],
});
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { name: "" } } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
expect(validationCtx.validateAll()).toBe(false);
});
});
+267
View File
@@ -0,0 +1,267 @@
import {
computed,
defineComponent,
inject,
onMounted,
onUnmounted,
provide,
ref,
type ComputedRef,
type PropType,
} from "vue";
import {
runValidation,
type ValidationConfig,
type ValidationFunction,
type ValidationResult,
} from "@json-render/core";
import { useStateStore } from "./state";
/**
* Field validation state
*/
export interface FieldValidationState {
touched: boolean;
validated: boolean;
result: ValidationResult | null;
}
/**
* Validation context value
*/
export interface ValidationContextValue {
customFunctions: Record<string, ValidationFunction>;
fieldStates: Record<string, FieldValidationState>;
validate: (path: string, config: ValidationConfig) => ValidationResult;
touch: (path: string) => void;
clear: (path: string) => void;
validateAll: () => boolean;
registerField: (path: string, config: ValidationConfig) => void;
}
const VALIDATION_KEY = Symbol("json-render:validation");
export interface ValidationProviderProps {
customFunctions?: Record<string, ValidationFunction>;
}
/**
* Compare two args records shallowly.
*/
function dynamicArgsEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
const va = a[key];
const vb = b[key];
if (va === vb) continue;
if (
typeof va === "object" &&
va !== null &&
typeof vb === "object" &&
vb !== null
) {
const sa = (va as Record<string, unknown>).$state;
const sb = (vb as Record<string, unknown>).$state;
if (typeof sa === "string" && sa === sb) continue;
}
return false;
}
return true;
}
/**
* Structural equality check for ValidationConfig.
*/
function validationConfigEqual(
a: ValidationConfig,
b: ValidationConfig,
): boolean {
if (a === b) return true;
if (a.validateOn !== b.validateOn) return false;
const ac = a.checks ?? [];
const bc = b.checks ?? [];
if (ac.length !== bc.length) return false;
for (let i = 0; i < ac.length; i++) {
const ca = ac[i]!;
const cb = bc[i]!;
if (ca.type !== cb.type) return false;
if (ca.message !== cb.message) return false;
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
}
return true;
}
/**
* Provider for validation
*/
export const ValidationProvider = defineComponent({
name: "ValidationProvider",
props: {
customFunctions: {
type: Object as PropType<Record<string, ValidationFunction>>,
default: () => ({}),
},
},
setup(props, { slots }) {
const { state } = useStateStore();
const fieldStates = ref<Record<string, FieldValidationState>>({});
const fieldConfigs = ref<Record<string, ValidationConfig>>({});
const registerField = (path: string, config: ValidationConfig) => {
const existing = fieldConfigs.value[path];
if (existing && validationConfigEqual(existing, config)) return;
fieldConfigs.value = { ...fieldConfigs.value, [path]: config };
};
const validate = (
path: string,
config: ValidationConfig,
): ValidationResult => {
const currentState = state.value;
const segments = path.split("/").filter(Boolean);
let value: unknown = currentState;
for (const seg of segments) {
if (value != null && typeof value === "object") {
value = (value as Record<string, unknown>)[seg];
} else {
value = undefined;
break;
}
}
const result = runValidation(config, {
value,
stateModel: currentState,
customFunctions: props.customFunctions,
});
fieldStates.value = {
...fieldStates.value,
[path]: {
touched: fieldStates.value[path]?.touched ?? true,
validated: true,
result,
},
};
return result;
};
const touch = (path: string) => {
fieldStates.value = {
...fieldStates.value,
[path]: {
...fieldStates.value[path],
touched: true,
validated: fieldStates.value[path]?.validated ?? false,
result: fieldStates.value[path]?.result ?? null,
},
};
};
const clear = (path: string) => {
const { [path]: _, ...rest } = fieldStates.value;
fieldStates.value = rest;
};
const validateAll = (): boolean => {
let allValid = true;
for (const [path, config] of Object.entries(fieldConfigs.value)) {
const result = validate(path, config);
if (!result.valid) {
allValid = false;
}
}
return allValid;
};
provide<ValidationContextValue>(VALIDATION_KEY, {
get customFunctions() {
return props.customFunctions;
},
get fieldStates() {
return fieldStates.value;
},
validate,
touch,
clear,
validateAll,
registerField,
});
return () => slots.default?.();
},
});
/**
* Composable to access validation context (or null if not inside a ValidationProvider).
* Useful for components that optionally participate in form validation.
*/
export function useOptionalValidation(): ValidationContextValue | null {
return (
inject<ValidationContextValue>(
VALIDATION_KEY,
null as unknown as ValidationContextValue,
) ?? null
);
}
/**
* Composable to access validation context
*/
export function useValidation(): ValidationContextValue {
const ctx = inject<ValidationContextValue>(VALIDATION_KEY);
if (!ctx) {
throw new Error("useValidation must be used within a ValidationProvider");
}
return ctx;
}
/**
* Composable to get validation state for a field
*/
export function useFieldValidation(
path: string,
config?: ValidationConfig,
): {
state: ComputedRef<FieldValidationState>;
validate: () => ValidationResult;
touch: () => void;
clear: () => void;
errors: ComputedRef<string[]>;
isValid: ComputedRef<boolean>;
} {
const ctx = useValidation();
onMounted(() => {
if (path && config) {
ctx.registerField(path, config);
}
});
onUnmounted(() => {
ctx.clear(path);
});
const defaultState: FieldValidationState = {
touched: false,
validated: false,
result: null,
};
return {
state: computed(() => ctx.fieldStates[path] ?? defaultState),
validate: () => ctx.validate(path, config ?? { checks: [] }),
touch: () => ctx.touch(path),
clear: () => ctx.clear(path),
errors: computed(() => ctx.fieldStates[path]?.result?.errors ?? []),
isValid: computed(() => ctx.fieldStates[path]?.result?.valid ?? true),
};
}
@@ -0,0 +1,97 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component, type ComputedRef } from "vue";
import { mount } from "@vue/test-utils";
import { StateProvider, useStateStore } from "./state";
import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
/** Mount StateProvider → VisibilityProvider with a child that captures context. */
function withProviders<T>(
composable: () => T,
initialState: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState } as any,
slots: {
default: () =>
h(VisibilityProvider as Component, null, {
default: () => h(Child),
}),
},
});
return { result };
}
describe("VisibilityProvider — provide/inject", () => {
it("useVisibility() throws outside a VisibilityProvider", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => useVisibility()).toThrow(
"useVisibility must be used within a VisibilityProvider",
);
warn.mockRestore();
});
it("useVisibility() returns a context with isVisible and ctx", () => {
const { result } = withProviders(() => useVisibility());
expect(typeof result.isVisible).toBe("function");
expect(result.ctx).toBeDefined();
expect(result.ctx.value).toBeDefined();
});
});
describe("useIsVisible — state integration", () => {
it("undefined condition returns true", () => {
const { result } = withProviders(() => useVisibility());
expect(result.isVisible(undefined)).toBe(true);
});
it("{ $state: '/flag' } returns true when state flag is truthy", () => {
const { result } = withProviders(() => useVisibility(), { flag: true });
expect(result.isVisible({ $state: "/flag" })).toBe(true);
});
it("{ $state: '/flag' } returns false when state flag is falsy", () => {
const { result } = withProviders(() => useVisibility(), { flag: false });
expect(result.isVisible({ $state: "/flag" })).toBe(false);
});
it("ctx is a ComputedRef whose .value reflects current state", () => {
const { result } = withProviders(() => useVisibility(), { count: 3 });
expect(result.ctx.value.stateModel).toEqual({ count: 3 });
});
});
describe("useIsVisible — reactivity", () => {
it("returns a ComputedRef<boolean> that updates when state changes", () => {
let storeCtx!: ReturnType<typeof useStateStore>;
let isVisible!: ComputedRef<boolean>;
const Child = defineComponent({
setup() {
storeCtx = useStateStore();
isVisible = useIsVisible({ $state: "/flag" });
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { flag: false } } as any,
slots: {
default: () =>
h(VisibilityProvider as Component, null, {
default: () => h(Child),
}),
},
});
expect(isVisible.value).toBe(false);
storeCtx.set("/flag", true);
expect(isVisible.value).toBe(true);
});
});
@@ -0,0 +1,68 @@
import {
computed,
defineComponent,
inject,
provide,
type ComputedRef,
} from "vue";
import {
evaluateVisibility,
type VisibilityCondition,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import { useStateStore } from "./state";
/**
* Visibility context value
*/
export interface VisibilityContextValue {
/** Evaluate a visibility condition */
isVisible: (condition: VisibilityCondition | undefined) => boolean;
/** The underlying visibility context (reactive) */
ctx: ComputedRef<CoreVisibilityContext>;
}
const VISIBILITY_KEY = Symbol("json-render:visibility");
/**
* Provider for visibility evaluation
*/
export const VisibilityProvider = defineComponent({
name: "VisibilityProvider",
setup(_, { slots }) {
const { state } = useStateStore();
const ctx = computed<CoreVisibilityContext>(() => ({
stateModel: state.value,
}));
const isVisible = (condition: VisibilityCondition | undefined): boolean =>
evaluateVisibility(condition, ctx.value);
provide<VisibilityContextValue>(VISIBILITY_KEY, { isVisible, ctx });
return () => slots.default?.();
},
});
/**
* Composable to access visibility evaluation
*/
export function useVisibility(): VisibilityContextValue {
const ctx = inject<VisibilityContextValue>(VISIBILITY_KEY);
if (!ctx) {
throw new Error("useVisibility must be used within a VisibilityProvider");
}
return ctx;
}
/**
* Composable to check if a condition is visible. Returns a reactive
* `ComputedRef<boolean>` so the result updates whenever state changes.
*/
export function useIsVisible(
condition: VisibilityCondition | undefined,
): ComputedRef<boolean> {
const { ctx } = useVisibility();
return computed(() => evaluateVisibility(condition, ctx.value));
}
+482
View File
@@ -0,0 +1,482 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, nextTick, type Component } from "vue";
import { mount } from "@vue/test-utils";
import type { Spec } from "@json-render/core";
import { StateProvider, useStateStore } from "./composables/state";
import { VisibilityProvider } from "./composables/visibility";
import { ActionProvider } from "./composables/actions";
import { ValidationProvider } from "./composables/validation";
import { useFieldValidation } from "./composables/validation";
import { useBoundProp } from "./hooks";
import {
Renderer,
JSONUIProvider,
type ComponentRegistry,
type ComponentRenderProps,
} from "./renderer";
// =============================================================================
// Stub components
// =============================================================================
const Button = defineComponent({
name: "Button",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
return () =>
h(
"button",
{ "data-testid": "btn", onClick: () => props.emit("press") },
String((props.element as any).props?.label ?? ""),
);
},
});
const Text = defineComponent({
name: "Text",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
return () => {
const value = (props.element as any).props?.text;
return h(
"span",
{ "data-testid": "text" },
value == null
? ""
: typeof value === "string"
? value
: JSON.stringify(value),
);
};
},
});
const Stack = defineComponent({
name: "Stack",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(_props, { slots }) {
return () => h("div", { "data-testid": "stack" }, slots.default?.());
},
});
const InputField = defineComponent({
name: "InputField",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
const elProps = (props.element as any).props ?? {};
const bindingPath = (props.bindings as Record<string, string>)?.value;
const [boundValue, setBoundValue] = useBoundProp<string>(
elProps.value as string | undefined,
bindingPath,
);
const hasValidation = !!(bindingPath && elProps.checks?.length);
const config = hasValidation ? { checks: elProps.checks ?? [] } : undefined;
const { errors } = useFieldValidation(bindingPath ?? "", config);
return () =>
h("div", null, [
elProps.label ? h("label", null, elProps.label) : null,
h("input", {
"data-testid": "input",
value: boundValue ?? "",
onInput: (e: Event) =>
setBoundValue((e.target as HTMLInputElement).value),
}),
errors.value.length > 0
? h("span", { "data-testid": "input-error" }, errors.value[0])
: null,
]);
},
});
const Select = defineComponent({
name: "Select",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
const elProps = (props.element as any).props ?? {};
const bindingPath = (props.bindings as Record<string, string>)?.value;
const [boundValue] = useBoundProp<string>(
elProps.value as string | undefined,
bindingPath,
);
return () => h("span", { "data-testid": "select-value" }, boundValue ?? "");
},
});
const registry: ComponentRegistry = {
Button: Button as unknown as Component,
Text: Text as unknown as Component,
Input: InputField as unknown as Component,
Select: Select as unknown as Component,
Stack: Stack as unknown as Component,
};
// State probe to read state from tests
const StateProbe = defineComponent({
name: "StateProbe",
setup() {
const { state } = useStateStore();
return () =>
h("pre", { "data-testid": "state-probe" }, JSON.stringify(state.value));
},
});
function getState(wrapper: ReturnType<typeof mount>): Record<string, unknown> {
return JSON.parse(wrapper.find("[data-testid='state-probe']").text());
}
// =============================================================================
// Mount helper
// =============================================================================
function mountWithProviders(
spec: Spec,
opts: {
functions?: Record<string, (args: Record<string, unknown>) => unknown>;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
} = {},
) {
return mount(JSONUIProvider as Component, {
props: {
registry,
initialState: opts.initialState ?? spec.state ?? {},
functions: opts.functions,
handlers: opts.handlers,
} as any,
slots: {
default: () => [h(Renderer, { spec, registry }), h(StateProbe)],
},
});
}
// =============================================================================
// $computed expressions in rendering
// =============================================================================
describe("$computed expressions in rendering", () => {
it("resolves a $computed prop using provided functions", () => {
const spec: Spec = {
state: { first: "Jane", last: "Doe" },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$computed: "fullName",
args: {
first: { $state: "/first" },
last: { $state: "/last" },
},
},
},
children: [],
},
},
};
const functions = {
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
};
const wrapper = mountWithProviders(spec, { functions });
expect(wrapper.find("[data-testid='text']").text()).toBe("Jane Doe");
});
it("renders gracefully when functions prop is omitted", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $computed: "missing" },
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec);
expect(wrapper.find("[data-testid='text']").text()).toBe("");
warnSpy.mockRestore();
});
});
// =============================================================================
// Watchers
// =============================================================================
describe("watchers (watch field)", () => {
it("does not fire on initial render, fires when watched state changes", async () => {
const loadCities = vi.fn();
const spec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec, { handlers: { loadCities } });
expect(loadCities).not.toHaveBeenCalled();
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
await nextTick();
expect(loadCities).toHaveBeenCalledTimes(1);
expect(loadCities).toHaveBeenCalledWith(
expect.objectContaining({ country: "US" }),
);
});
it("fires multiple action bindings on the same watch path", async () => {
const action1 = vi.fn();
const action2 = vi.fn();
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: [
{
action: "setState",
params: { statePath: "/value", value: "b" },
},
],
},
children: [],
},
watcher: {
type: "Text",
props: { text: { $state: "/value" } },
watch: {
"/value": [{ action: "action1" }, { action: "action2" }],
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec, {
handlers: { action1, action2 },
});
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
await nextTick();
expect(action1).toHaveBeenCalledTimes(1);
expect(action2).toHaveBeenCalledTimes(1);
});
});
// =============================================================================
// validateForm action
// =============================================================================
describe("validateForm action", () => {
it("writes { valid: false, errors } when a required field is empty", async () => {
const spec: Spec = {
state: { form: { email: "" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
});
it("writes { valid: true, errors: {} } when all fields pass validation", async () => {
const spec: Spec = {
state: { form: { email: "test@example.com" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.result).toEqual({ valid: true, errors: {} });
});
it("defaults to /formValidation when no statePath is provided", async () => {
const spec: Spec = {
state: { form: { name: "filled" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["nameInput", "submitBtn"],
},
nameInput: {
type: "Input",
props: {
label: "Name",
value: { $bindState: "/form/name" },
checks: [{ type: "required", message: "Required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [{ action: "validateForm" }],
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.formValidation).toEqual({ valid: true, errors: {} });
});
});
+347
View File
@@ -0,0 +1,347 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { defineComponent, h, ref, type Component } from "vue";
import { mount } from "@vue/test-utils";
import { SPEC_DATA_PART_TYPE } from "@json-render/core";
import { StateProvider, useStateStore } from "./composables/state";
import {
flatToTree,
buildSpecFromParts,
getTextFromParts,
useBoundProp,
useUIStream,
useJsonRenderMessage,
type DataPart,
} from "./hooks";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/** Mount inside a StateProvider and capture a composable's result. */
function withStateProvider<T>(
composable: () => T,
initialState: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState } as any,
slots: { default: () => h(Child) },
});
return { result };
}
/** Create a simple ReadableStream from a string (for fetch mocks). */
function makeReadableStream(text: string): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(text));
controller.close();
},
});
}
// ---------------------------------------------------------------------------
// flatToTree
// ---------------------------------------------------------------------------
describe("flatToTree", () => {
it("converts flat elements into a Spec with root and children", () => {
const elements = [
{ key: "root", type: "Stack", props: {}, parentKey: undefined },
{
key: "child1",
type: "Text",
props: { content: "hello" },
parentKey: "root",
},
];
const spec = flatToTree(elements as any);
expect(spec.root).toBe("root");
expect(spec.elements["root"]?.children).toContain("child1");
expect(spec.elements["child1"]).toBeDefined();
});
it("handles a single root element with no children", () => {
const elements = [
{ key: "root", type: "Text", props: {}, parentKey: undefined },
];
const spec = flatToTree(elements as any);
expect(spec.root).toBe("root");
expect(spec.elements["root"]?.children).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// buildSpecFromParts
// ---------------------------------------------------------------------------
describe("buildSpecFromParts", () => {
it("returns null when no spec parts present", () => {
const parts: DataPart[] = [{ type: "text", text: "hello" }];
expect(buildSpecFromParts(parts)).toBeNull();
});
it("returns null for empty array", () => {
expect(buildSpecFromParts([])).toBeNull();
});
it("returns a Spec when a snapshot spec data part is present", () => {
const part: DataPart = {
type: SPEC_DATA_PART_TYPE,
data: {
type: "flat",
spec: {
root: "r",
elements: { r: { type: "Text", props: { content: "hi" } } },
},
},
};
const result = buildSpecFromParts([part]);
expect(result?.root).toBe("r");
expect(result?.elements["r"]).toBeDefined();
});
it("applies patch operations incrementally", () => {
const parts: DataPart[] = [
{
type: SPEC_DATA_PART_TYPE,
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "myRoot" },
},
},
];
const result = buildSpecFromParts(parts);
expect(result?.root).toBe("myRoot");
});
it("skips malformed data parts silently", () => {
const parts: DataPart[] = [
{ type: SPEC_DATA_PART_TYPE, data: { type: "unknown" } },
];
expect(buildSpecFromParts(parts)).toBeNull();
});
});
// ---------------------------------------------------------------------------
// getTextFromParts
// ---------------------------------------------------------------------------
describe("getTextFromParts", () => {
it("concatenates text parts with double newlines", () => {
const parts: DataPart[] = [
{ type: "text", text: "hello" },
{ type: "text", text: "world" },
];
expect(getTextFromParts(parts)).toBe("hello\n\nworld");
});
it("ignores non-text parts", () => {
const parts: DataPart[] = [
{ type: "other", data: {} },
{ type: "text", text: "hi" },
];
expect(getTextFromParts(parts)).toBe("hi");
});
it("returns empty string for empty array", () => {
expect(getTextFromParts([])).toBe("");
});
it("filters out empty/whitespace text parts", () => {
const parts: DataPart[] = [
{ type: "text", text: " " },
{ type: "text", text: "real" },
];
expect(getTextFromParts(parts)).toBe("real");
});
});
// ---------------------------------------------------------------------------
// useBoundProp
// ---------------------------------------------------------------------------
describe("useBoundProp", () => {
it("returns the prop value and a no-op setter when no binding path", () => {
const { result } = withStateProvider(() =>
useBoundProp("hello", undefined),
);
const [value, setValue] = result;
expect(value).toBe("hello");
expect(() => setValue("new")).not.toThrow();
});
it("returns undefined prop value when propValue is undefined", () => {
const { result } = withStateProvider(() =>
useBoundProp<string>(undefined, undefined),
);
expect(result[0]).toBeUndefined();
});
it("setter writes to state at the binding path", () => {
let storeCtx!: ReturnType<typeof useStateStore>;
let setter!: (v: string) => void;
const Child = defineComponent({
setup() {
storeCtx = useStateStore();
const [, s] = useBoundProp<string>("Alice", "/name");
setter = s;
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { name: "Alice" } } as any,
slots: { default: () => h(Child) },
});
setter("Bob");
expect(storeCtx.get("/name")).toBe("Bob");
});
});
// ---------------------------------------------------------------------------
// useUIStream
// ---------------------------------------------------------------------------
describe("useUIStream", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("send() sets isStreaming true then false after completion", async () => {
const patchLine =
JSON.stringify({ op: "add", path: "/root", value: "myRoot" }) +
"\n" +
JSON.stringify({
op: "add",
path: "/elements/myRoot",
value: { type: "Text", props: {} },
}) +
"\n";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
body: makeReadableStream(patchLine),
}),
);
let streamResult!: ReturnType<typeof useUIStream>;
const Child = defineComponent({
setup() {
streamResult = useUIStream({ api: "/api/ui" });
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: { default: () => h(Child) },
});
expect(streamResult.isStreaming.value).toBe(false);
const promise = streamResult.send("build me a UI");
expect(streamResult.isStreaming.value).toBe(true);
await promise;
expect(streamResult.isStreaming.value).toBe(false);
expect(streamResult.spec.value?.root).toBe("myRoot");
});
it("error is set when fetch fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Network error")),
);
let streamResult!: ReturnType<typeof useUIStream>;
const Child = defineComponent({
setup() {
streamResult = useUIStream({ api: "/api/ui" });
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: { default: () => h(Child) },
});
await streamResult.send("fail");
expect(streamResult.error.value?.message).toBe("Network error");
});
it("clear() resets spec and error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("oops")));
let streamResult!: ReturnType<typeof useUIStream>;
const Child = defineComponent({
setup() {
streamResult = useUIStream({ api: "/api/ui" });
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: { default: () => h(Child) },
});
await streamResult.send("fail");
expect(streamResult.error.value).not.toBeNull();
streamResult.clear();
expect(streamResult.error.value).toBeNull();
expect(streamResult.spec.value).toBeNull();
});
});
// ---------------------------------------------------------------------------
// useJsonRenderMessage
// ---------------------------------------------------------------------------
describe("useJsonRenderMessage", () => {
it("returns null spec and false hasSpec when no spec parts", () => {
const { spec, hasSpec } = useJsonRenderMessage([
{ type: "text", text: "hi" },
]);
expect(spec.value).toBeNull();
expect(hasSpec.value).toBe(false);
});
it("extracts text from text parts", () => {
const { text } = useJsonRenderMessage([
{ type: "text", text: "hello" },
{ type: "text", text: "world" },
]);
expect(text.value).toBe("hello\n\nworld");
});
it("is reactive when passed a Ref<DataPart[]>", () => {
const parts = ref<DataPart[]>([]);
const { text, spec, hasSpec } = useJsonRenderMessage(parts);
expect(text.value).toBe("");
expect(spec.value).toBeNull();
parts.value = [{ type: "text", text: "hello" }];
expect(text.value).toBe("hello");
parts.value = [
...parts.value,
{
type: SPEC_DATA_PART_TYPE,
data: {
type: "flat",
spec: { root: "r", elements: { r: { type: "Text", props: {} } } },
},
},
];
expect(spec.value?.root).toBe("r");
expect(hasSpec.value).toBe(true);
});
});
+830
View File
@@ -0,0 +1,830 @@
import {
ref,
shallowRef,
computed,
onUnmounted,
isRef,
type Ref,
type ComputedRef,
} from "vue";
import { useStateStore } from "./composables/state";
import type {
Spec,
UIElement,
FlatElement,
JsonPatch,
SpecDataPart,
} from "@json-render/core";
import {
setByPath,
getByPath,
addByPath,
removeByPath,
createMixedStreamParser,
applySpecPatch,
nestedToFlat,
SPEC_DATA_PART_TYPE,
} from "@json-render/core";
/**
* Token usage metadata from AI generation
*/
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
/**
* Parse result for a single line -- either a patch or usage metadata
*/
type ParsedLine =
| { type: "patch"; patch: JsonPatch }
| { type: "usage"; usage: TokenUsage }
| null;
/**
* Parse a single JSON line (patch or metadata)
*/
function parseLine(line: string): ParsedLine {
try {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) {
return null;
}
const parsed = JSON.parse(trimmed);
// Check for usage metadata
if (parsed.__meta === "usage") {
return {
type: "usage",
usage: {
promptTokens: parsed.promptTokens ?? 0,
completionTokens: parsed.completionTokens ?? 0,
totalTokens: parsed.totalTokens ?? 0,
},
};
}
return { type: "patch", patch: parsed as JsonPatch };
} catch {
return null;
}
}
/**
* Set a value at a spec path (for add/replace operations).
*/
function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
if (path === "/root") {
newSpec.root = value as string;
return;
}
if (path === "/state") {
newSpec.state = value as Record<string, unknown>;
return;
}
if (path.startsWith("/state/")) {
if (!newSpec.state) newSpec.state = {};
const statePath = path.slice("/state".length); // e.g. "/posts"
setByPath(newSpec.state as Record<string, unknown>, statePath, value);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
newSpec.elements[elementKey] = value as UIElement;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
setByPath(
newElement as unknown as Record<string, unknown>,
propPath,
value,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
/**
* Remove a value at a spec path.
*/
function removeSpecValue(newSpec: Spec, path: string): void {
if (path === "/state") {
delete newSpec.state;
return;
}
if (path.startsWith("/state/") && newSpec.state) {
const statePath = path.slice("/state".length);
removeByPath(newSpec.state as Record<string, unknown>, statePath);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
const { [elementKey]: _, ...rest } = newSpec.elements;
newSpec.elements = rest;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
removeByPath(
newElement as unknown as Record<string, unknown>,
propPath,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
/**
* Get a value at a spec path.
*/
function getSpecValue(spec: Spec, path: string): unknown {
if (path === "/root") return spec.root;
if (path === "/state") return spec.state;
if (path.startsWith("/state/") && spec.state) {
const statePath = path.slice("/state".length);
return getByPath(spec.state as Record<string, unknown>, statePath);
}
return getByPath(spec as unknown as Record<string, unknown>, path);
}
/**
* Apply an RFC 6902 JSON patch to the current spec.
* Supports add, remove, replace, move, copy, and test operations.
*/
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const newSpec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace": {
setSpecValue(newSpec, patch.path, patch.value);
break;
}
case "remove": {
removeSpecValue(newSpec, patch.path);
break;
}
case "move": {
if (!patch.from) break;
const moveValue = getSpecValue(newSpec, patch.from);
removeSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, moveValue);
break;
}
case "copy": {
if (!patch.from) break;
const copyValue = getSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, copyValue);
break;
}
case "test": {
// test is a no-op for rendering purposes (validation only)
break;
}
}
return newSpec;
}
/**
* Options for useUIStream
*/
export interface UseUIStreamOptions {
/** API endpoint */
api: string;
/** Callback when complete */
onComplete?: (spec: Spec) => void;
/** Callback on error */
onError?: (error: Error) => void;
}
/**
* Return type for useUIStream
*/
export interface UseUIStreamReturn {
/** Current UI spec */
spec: Ref<Spec | null>;
/** Whether currently streaming */
isStreaming: Ref<boolean>;
/** Error if any */
error: Ref<Error | null>;
/** Token usage from the last generation */
usage: Ref<TokenUsage | null>;
/** Raw JSONL lines received from the stream (JSON patch lines) */
rawLines: Ref<string[]>;
/** Send a prompt to generate UI */
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
/** Clear the current spec */
clear: () => void;
}
/**
* Composable for streaming UI generation
*/
export function useUIStream({
api,
onComplete,
onError,
}: UseUIStreamOptions): UseUIStreamReturn {
const spec = shallowRef<Spec | null>(null);
const isStreaming = ref(false);
const error = ref<Error | null>(null);
const usage = ref<TokenUsage | null>(null);
const rawLines = ref<string[]>([]);
const onCompleteRef = ref(onComplete);
const onErrorRef = ref(onError);
let abortController: AbortController | null = null;
const clear = () => {
spec.value = null;
error.value = null;
usage.value = null;
rawLines.value = [];
};
const send = async (
prompt: string,
context?: Record<string, unknown>,
): Promise<void> => {
// Abort any existing request
abortController?.abort();
abortController = new AbortController();
isStreaming.value = true;
error.value = null;
usage.value = null;
rawLines.value = [];
// Start with previous spec if provided, otherwise empty spec
const previousSpec = context?.previousSpec as Spec | undefined;
let currentSpec: Spec =
previousSpec && previousSpec.root
? { ...previousSpec, elements: { ...previousSpec.elements } }
: { root: "", elements: {} };
spec.value = currentSpec;
try {
const response = await fetch(api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
context,
currentSpec,
}),
signal: abortController.signal,
});
if (!response.ok) {
// Try to parse JSON error response for better error messages
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors, use default message
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete lines
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const result = parseLine(trimmed);
if (!result) continue;
if (result.type === "usage") {
usage.value = result.usage;
} else {
rawLines.value = [...rawLines.value, trimmed];
currentSpec = applyPatch(currentSpec, result.patch);
spec.value = { ...currentSpec };
}
}
}
// Process any remaining buffer
if (buffer.trim()) {
const trimmed = buffer.trim();
const result = parseLine(trimmed);
if (result) {
if (result.type === "usage") {
usage.value = result.usage;
} else {
rawLines.value = [...rawLines.value, trimmed];
currentSpec = applyPatch(currentSpec, result.patch);
spec.value = { ...currentSpec };
}
}
}
onCompleteRef.value?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
error.value = resolvedError;
onErrorRef.value?.(resolvedError);
} finally {
isStreaming.value = false;
}
};
// Cleanup on unmount
onUnmounted(() => {
abortController?.abort();
});
return {
spec,
isStreaming,
error,
usage,
rawLines,
send,
clear,
};
}
/**
* Convert a flat element list to a Spec.
* Input elements use key/parentKey to establish identity and relationships.
* Output spec uses the map-based format where key is the map entry key
* and parent-child relationships are expressed through children arrays.
*/
export function flatToTree(elements: FlatElement[]): Spec {
const elementMap: Record<string, UIElement> = {};
let root = "";
// First pass: add all elements to map
for (const element of elements) {
elementMap[element.key] = {
type: element.type,
props: element.props,
children: [],
visible: element.visible,
};
}
// Second pass: build parent-child relationships
for (const element of elements) {
if (element.parentKey) {
const parent = elementMap[element.parentKey];
if (parent) {
if (!parent.children) {
parent.children = [];
}
parent.children.push(element.key);
}
} else {
root = element.key;
}
}
return { root, elements: elementMap };
}
// =============================================================================
// useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
// =============================================================================
/**
* Composable for two-way bound props. Returns `[value, setValue]` where:
*
* - `value` is the already-resolved prop value (passed through from render props)
* - `setValue` writes back to the bound state path (no-op if not bound)
*
* Designed to work with the `bindings` map that the renderer provides when
* a prop uses `{ $bindState: "/path" }` or `{ $bindItem: "field" }`.
*
* @example
* ```ts
* import { useBoundProp } from "@json-render/vue";
*
* const Input: ComponentFn<AppCatalog, "Input"> = ({ props, bindings }) => {
* const [value, setValue] = useBoundProp<string>(props.value as string, bindings?.value);
* return h("input", { value: value ?? "", onInput: (e) => setValue(e.target.value) });
* };
* ```
*/
export function useBoundProp<T>(
propValue: T | undefined,
bindingPath: string | undefined,
): [T | undefined, (value: T) => void] {
const { set } = useStateStore();
return [
propValue,
(value: T) => {
if (bindingPath) set(bindingPath, value);
},
];
}
// =============================================================================
// buildSpecFromParts — Derive Spec from AI SDK data parts
// =============================================================================
/**
* A single part from the AI SDK's `message.parts` array. This is a minimal
* structural type so that library helpers do not depend on the AI SDK.
* Fields are optional because different part types carry different data:
* - Text parts have `text`
* - Data parts have `data`
*/
export interface DataPart {
type: string;
text?: string;
data?: unknown;
}
/**
* Type guard that validates a data part payload looks like a valid
* SpecDataPart before we cast it.
*/
function isSpecDataPart(data: unknown): data is SpecDataPart {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
switch (obj.type) {
case "patch":
return typeof obj.patch === "object" && obj.patch !== null;
case "flat":
case "nested":
return typeof obj.spec === "object" && obj.spec !== null;
default:
return false;
}
}
/**
* Build a `Spec` by replaying all spec data parts from a message's
* parts array. Returns `null` if no spec data parts are present.
*
* Works with the AI SDK's `UIMessage.parts` array. Picks out parts whose
* `type` is `SPEC_DATA_PART_TYPE` and processes them based on the payload's
* `type` discriminator: `"patch"`, `"flat"`, or `"nested"`.
*/
export function buildSpecFromParts(parts: DataPart[]): Spec | null {
const spec: Spec = { root: "", elements: {} };
let hasSpec = false;
for (const part of parts) {
if (part.type === SPEC_DATA_PART_TYPE) {
if (!isSpecDataPart(part.data)) continue;
const payload = part.data;
if (payload.type === "patch") {
hasSpec = true;
applySpecPatch(spec, payload.patch);
} else if (payload.type === "flat") {
hasSpec = true;
Object.assign(spec, payload.spec);
} else if (payload.type === "nested") {
hasSpec = true;
const flat = nestedToFlat(payload.spec);
Object.assign(spec, flat);
}
}
}
return hasSpec ? spec : null;
}
/**
* Extract and join all text content from a message's parts array.
*
* Filters for parts with `type === "text"`, trims each one, and joins them
* with double newlines so that text from separate agent steps renders as
* distinct paragraphs in markdown.
*/
export function getTextFromParts(parts: DataPart[]): string {
return parts
.filter(
(p): p is DataPart & { text: string } =>
p.type === "text" && typeof p.text === "string",
)
.map((p) => p.text.trim())
.filter(Boolean)
.join("\n\n");
}
// =============================================================================
// useJsonRenderMessage — extract spec + text from message parts
// =============================================================================
/**
* Composable that extracts both the json-render spec and text content from a
* message's parts array. Accepts a plain `DataPart[]` or a `Ref<DataPart[]>`
* for reactive use in streaming scenarios.
*
* Returns `ComputedRef`s that recompute whenever `parts` changes.
*
* @example
* ```ts
* import { useJsonRenderMessage } from "@json-render/vue";
*
* const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
* ```
*/
export function useJsonRenderMessage(parts: DataPart[] | Ref<DataPart[]>): {
spec: ComputedRef<Spec | null>;
text: ComputedRef<string>;
hasSpec: ComputedRef<boolean>;
} {
const partsRef = isRef(parts) ? parts : ref(parts);
const spec = computed(() => buildSpecFromParts(partsRef.value));
const text = computed(() => getTextFromParts(partsRef.value));
const hasSpec = computed(
() =>
spec.value !== null && Object.keys(spec.value.elements || {}).length > 0,
);
return { spec, text, hasSpec };
}
// =============================================================================
// useChatUI — Chat + GenUI composable
// =============================================================================
/**
* A single message in the chat, which may contain text, a rendered UI spec, or both.
*/
export interface ChatMessage {
/** Unique message ID */
id: string;
/** Who sent this message */
role: "user" | "assistant";
/** Text content (conversational prose) */
text: string;
/** json-render Spec built from JSONL patches (null if no UI was generated) */
spec: Spec | null;
}
/**
* Options for useChatUI
*/
export interface UseChatUIOptions {
/** API endpoint that accepts `{ messages: Array<{ role, content }> }` and returns a text stream */
api: string;
/** Callback when streaming completes for a message */
onComplete?: (message: ChatMessage) => void;
/** Callback on error */
onError?: (error: Error) => void;
}
/**
* Return type for useChatUI
*/
export interface UseChatUIReturn {
/** All messages in the conversation */
messages: Ref<ChatMessage[]>;
/** Whether currently streaming an assistant response */
isStreaming: Ref<boolean>;
/** Error from the last request, if any */
error: Ref<Error | null>;
/** Send a user message */
send: (text: string) => Promise<void>;
/** Clear all messages and reset the conversation */
clear: () => void;
}
let chatMessageIdCounter = 0;
function generateChatId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
chatMessageIdCounter += 1;
return `msg-${Date.now()}-${chatMessageIdCounter}`;
}
/**
* Composable for chat + GenUI experiences.
*
* Manages a multi-turn conversation where each assistant message can contain
* both conversational text and a json-render UI spec. Sends the full message
* history to the API endpoint, reads the streamed response, and separates
* text lines from JSONL patch lines using `createMixedStreamParser`.
*
* @example
* ```ts
* const { messages, isStreaming, send, clear } = useChatUI({ api: "/api/chat" });
*
* await send("Compare weather in NYC and Tokyo");
* ```
*/
export function useChatUI({
api,
onComplete,
onError,
}: UseChatUIOptions): UseChatUIReturn {
const messages = ref<ChatMessage[]>([]);
const isStreaming = ref(false);
const error = ref<Error | null>(null);
const onCompleteRef = ref(onComplete);
const onErrorRef = ref(onError);
let abortController: AbortController | null = null;
const clear = () => {
messages.value = [];
error.value = null;
};
const send = async (text: string): Promise<void> => {
if (!text.trim()) return;
// Abort any existing request
abortController?.abort();
abortController = new AbortController();
const userMessage: ChatMessage = {
id: generateChatId(),
role: "user",
text: text.trim(),
spec: null,
};
const assistantId = generateChatId();
const assistantMessage: ChatMessage = {
id: assistantId,
role: "assistant",
text: "",
spec: null,
};
// Append user message and empty assistant placeholder
messages.value = [...messages.value, userMessage, assistantMessage];
isStreaming.value = true;
error.value = null;
// Build messages array for the API (full conversation history + new message).
// Vue refs are always current — no stale closure issue unlike React useRef.
const historyForApi = [
...messages.value
.filter((m) => m.id !== assistantId)
.map((m) => ({ role: m.role, content: m.text })),
{ role: "user" as const, content: text.trim() },
];
// Mutable state for accumulating the assistant response
let accumulatedText = "";
let currentSpec: Spec = { root: "", elements: {} };
let hasSpec = false;
try {
const response = await fetch(api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: historyForApi }),
signal: abortController.signal,
});
if (!response.ok) {
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
// Use createMixedStreamParser to classify lines
const parser = createMixedStreamParser({
onPatch(patch) {
hasSpec = true;
applySpecPatch(currentSpec, patch);
messages.value = messages.value.map((m) =>
m.id === assistantId
? {
...m,
spec: {
root: currentSpec.root,
elements: { ...currentSpec.elements },
...(currentSpec.state
? { state: { ...currentSpec.state } }
: {}),
},
}
: m,
);
},
onText(line) {
accumulatedText += (accumulatedText ? "\n" : "") + line;
messages.value = messages.value.map((m) =>
m.id === assistantId ? { ...m, text: accumulatedText } : m,
);
},
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
parser.push(decoder.decode(value, { stream: true }));
}
parser.flush();
// Build final message for onComplete callback
const finalMessage: ChatMessage = {
id: assistantId,
role: "assistant",
text: accumulatedText,
spec: hasSpec
? {
root: currentSpec.root,
elements: { ...currentSpec.elements },
...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
}
: null,
};
onCompleteRef.value?.(finalMessage);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
error.value = resolvedError;
// Remove empty assistant message on error
messages.value = messages.value.filter(
(m) => m.id !== assistantId || m.text.length > 0,
);
onErrorRef.value?.(resolvedError);
} finally {
isStreaming.value = false;
}
};
// Cleanup on unmount
onUnmounted(() => {
abortController?.abort();
});
return {
messages,
isStreaming,
error,
send,
clear,
};
}
+99
View File
@@ -0,0 +1,99 @@
// Composables & Providers
export {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
type StateContextValue,
type StateProviderProps,
} from "./composables/state";
export {
VisibilityProvider,
useVisibility,
useIsVisible,
type VisibilityContextValue,
} from "./composables/visibility";
export {
ActionProvider,
useActions,
useAction,
ConfirmDialog,
type ActionContextValue,
type ActionProviderProps,
type PendingConfirmation,
type ConfirmDialogProps,
} from "./composables/actions";
export {
ValidationProvider,
useOptionalValidation,
useValidation,
useFieldValidation,
type ValidationContextValue,
type ValidationProviderProps,
type FieldValidationState,
} from "./composables/validation";
export {
RepeatScopeProvider,
useRepeatScope,
type RepeatScopeValue,
} from "./composables/repeat-scope";
// Schema
export { schema, type VueSchema, type VueSpec } from "./schema";
// Core types (re-exported for convenience)
export type { Spec, StateStore, ComputedFunction } from "@json-render/core";
export { createStateStore } from "@json-render/core";
// Catalog-aware types for Vue
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "./catalog-types";
// Hooks
export {
useUIStream,
useChatUI,
useBoundProp,
flatToTree,
buildSpecFromParts,
getTextFromParts,
useJsonRenderMessage,
type UseUIStreamOptions,
type UseUIStreamReturn,
type UseChatUIOptions,
type UseChatUIReturn,
type ChatMessage,
type DataPart,
type TokenUsage,
} from "./hooks";
// Renderer
export {
// Registry
defineRegistry,
type DefineRegistryResult,
// createRenderer (higher-level, includes providers)
createRenderer,
type CreateRendererProps,
type ComponentMap,
// Low-level
Renderer,
JSONUIProvider,
type ComponentRenderProps,
type ComponentRegistry,
type RendererProps,
type JSONUIProviderProps,
} from "./renderer";
+200
View File
@@ -0,0 +1,200 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import type { Spec } from "@json-render/core";
import { StateProvider } from "./composables/state";
import { VisibilityProvider } from "./composables/visibility";
import { ActionProvider } from "./composables/actions";
import { ValidationProvider } from "./composables/validation";
import { Renderer, defineRegistry, type ComponentRegistry } from "./renderer";
// ---------------------------------------------------------------------------
// Minimal test catalog and registry
// ---------------------------------------------------------------------------
// defineRegistry ignores the catalog object at runtime — use any cast
const catalog = {} as any;
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => {
const p = props as Record<string, unknown>;
return h(
"div",
{ "data-type": "card", "data-title": String(p["title"] ?? "") },
[String(p["title"] ?? ""), children],
);
},
Button: ({ props, emit }) => {
const p = props as Record<string, unknown>;
return h(
"button",
{ "data-type": "button", onClick: () => emit("press") },
String(p["label"] ?? ""),
);
},
},
});
// ---------------------------------------------------------------------------
// Mount helper: wraps in the full provider chain required by ElementRenderer
// ---------------------------------------------------------------------------
function mountRenderer(
spec: Spec | null,
reg: ComponentRegistry = registry,
extraProps: Record<string, unknown> = {},
handlers: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {},
initialState: Record<string, unknown> = {},
) {
return mount(StateProvider as Component, {
props: { initialState } as any,
slots: {
default: () =>
h(VisibilityProvider as Component, null, {
default: () =>
h(ValidationProvider as Component, null, {
default: () =>
h(ActionProvider as Component, { handlers } as any, {
default: () =>
h(Renderer, { spec, registry: reg, ...extraProps }),
}),
}),
}),
},
});
}
// ---------------------------------------------------------------------------
// defineRegistry tests
// ---------------------------------------------------------------------------
describe("defineRegistry", () => {
it("wraps a render function and returns a Vue component", () => {
const result = defineRegistry(catalog, {
components: {
MyComp: () => h("span", null, "hello"),
},
});
expect(result.registry).toBeDefined();
expect(result.registry["MyComp"]).toBeDefined();
});
it("component receives resolved props from the spec element", () => {
const spec: Spec = {
root: "btn1",
elements: {
btn1: { type: "Button", props: { label: "Press me" } },
},
};
const wrapper = mountRenderer(spec);
expect(wrapper.find("[data-type='button']").text()).toBe("Press me");
});
it("children is passed for container components", () => {
const spec: Spec = {
root: "card1",
elements: {
card1: {
type: "Card",
props: { title: "My Card" },
children: ["btn1"],
},
btn1: { type: "Button", props: { label: "Click" } },
},
};
const wrapper = mountRenderer(spec);
expect(wrapper.find("[data-type='card']").exists()).toBe(true);
expect(wrapper.find("[data-type='button']").exists()).toBe(true);
});
it("emit('press') fires the corresponding on.press action", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const spec: Spec = {
root: "btn1",
elements: {
btn1: {
type: "Button",
props: { label: "Click" },
on: { press: { action: "myAction" } },
},
},
};
const wrapper = mountRenderer(spec, registry, {}, { myAction: handler });
await wrapper.find("[data-type='button']").trigger("click");
expect(handler).toHaveBeenCalledOnce();
});
});
// ---------------------------------------------------------------------------
// Renderer tests
// ---------------------------------------------------------------------------
describe("Renderer", () => {
it("renders a single-element spec", () => {
const spec: Spec = {
root: "btn1",
elements: {
btn1: { type: "Button", props: { label: "Go" } },
},
};
const wrapper = mountRenderer(spec);
expect(wrapper.find("[data-type='button']").exists()).toBe(true);
expect(wrapper.find("[data-type='button']").text()).toBe("Go");
});
it("renders a nested spec (parent contains a child by key reference)", () => {
const spec: Spec = {
root: "card1",
elements: {
card1: {
type: "Card",
props: { title: "Root" },
children: ["btn1"],
},
btn1: { type: "Button", props: { label: "Action" } },
},
};
const wrapper = mountRenderer(spec);
const card = wrapper.find("[data-type='card']");
expect(card.exists()).toBe(true);
expect(card.find("[data-type='button']").exists()).toBe(true);
});
it("uses fallback component for unknown element types", () => {
const fallback = defineComponent({
setup() {
return () => h("span", { "data-type": "fallback" }, "fallback");
},
});
const spec: Spec = {
root: "el1",
elements: {
el1: { type: "Unknown", props: {} },
},
};
const wrapper = mountRenderer(spec, registry, { fallback });
expect(wrapper.find("[data-type='fallback']").exists()).toBe(true);
});
it("passes loading prop through to registered components", () => {
let receivedLoading: boolean | undefined;
const { registry: testRegistry } = defineRegistry(catalog, {
components: {
Widget: ({ loading }) => {
receivedLoading = loading;
return h("div", null, "widget");
},
},
});
const spec: Spec = {
root: "w1",
elements: { w1: { type: "Widget", props: {} } },
};
mountRenderer(spec, testRegistry, { loading: true });
expect(receivedLoading).toBe(true);
});
});
+961
View File
@@ -0,0 +1,961 @@
import {
computed,
defineComponent,
h,
inject,
onErrorCaptured,
provide,
ref,
watch,
type Component,
type ComputedRef,
type PropType,
type VNode,
} from "vue";
import type {
UIElement,
Spec,
ActionBinding,
Catalog,
ComputedFunction,
SchemaDefinition,
StateStore,
} from "@json-render/core";
import {
resolveElementProps,
resolveBindings,
resolveActionParam,
evaluateVisibility,
getByPath,
type PropResolutionContext,
} from "@json-render/core";
import type {
Components,
Actions,
ActionFn,
SetState,
StateModel,
CatalogHasActions,
EventHandle,
} from "./catalog-types";
import { useVisibility } from "./composables/visibility";
import { useActions } from "./composables/actions";
import { useStateStore } from "./composables/state";
import { StateProvider } from "./composables/state";
import { VisibilityProvider } from "./composables/visibility";
import { ActionProvider } from "./composables/actions";
import { ValidationProvider } from "./composables/validation";
import { ConfirmDialog } from "./composables/actions";
import {
RepeatScopeProvider,
useRepeatScope,
} from "./composables/repeat-scope";
/**
* Props passed to component renderers
*/
export interface ComponentRenderProps<P = Record<string, unknown>> {
/** The element being rendered */
element: UIElement<string, P>;
/** Emit a named event */
emit: (event: string) => void;
/** Get an event handle with metadata */
on: (event: string) => EventHandle;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
/** Whether the parent is loading */
loading?: boolean;
}
/**
* Registry of component renderers (Vue component definitions)
*/
export type ComponentRegistry = Record<string, Component>;
/**
* Props for the Renderer component
*/
export interface RendererProps {
spec: Spec | null;
registry: ComponentRegistry;
loading?: boolean;
fallback?: Component;
}
// ---------------------------------------------------------------------------
// FunctionsContext — provides $computed functions to the element tree
// ---------------------------------------------------------------------------
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
const FUNCTIONS_KEY = Symbol("json-render:functions");
const FunctionsProvider = defineComponent({
name: "FunctionsProvider",
props: {
functions: {
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
},
setup(props, { slots }) {
const fns = computed(() => props.functions ?? EMPTY_FUNCTIONS);
provide(FUNCTIONS_KEY, fns);
return () => slots.default?.();
},
});
function useFunctions(): ComputedRef<Record<string, ComputedFunction>> {
return inject<ComputedRef<Record<string, ComputedFunction>>>(
FUNCTIONS_KEY,
computed(() => EMPTY_FUNCTIONS),
);
}
// ---------------------------------------------------------------------------
// ElementErrorBoundary — catches rendering errors in individual elements
// ---------------------------------------------------------------------------
const ElementErrorBoundary = defineComponent({
name: "ElementErrorBoundary",
props: {
elementType: {
type: String,
required: true,
},
},
setup(props, { slots }) {
const hasError = ref(false);
onErrorCaptured((error) => {
console.error(
`[json-render] Rendering error in <${props.elementType}>:`,
error,
);
hasError.value = true;
return false; // prevent propagation
});
return () => {
if (hasError.value) return null;
return slots.default?.();
};
},
});
// ---------------------------------------------------------------------------
// resolveAndExecuteBindings — shared helper for emitEvent / watch handlers
// ---------------------------------------------------------------------------
async function resolveAndExecuteBindings(
actionBindings: ActionBinding[],
ctx: PropResolutionContext,
getSnapshot: () => Record<string, unknown>,
execute: (binding: ActionBinding) => Promise<void>,
cancelled?: () => boolean,
): Promise<void> {
for (const b of actionBindings) {
if (cancelled?.()) break;
if (!b.params) {
await execute(b);
if (cancelled?.()) break;
continue;
}
const liveCtx: PropResolutionContext = {
...ctx,
stateModel: getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
if (cancelled?.()) break;
}
}
// ---------------------------------------------------------------------------
// ElementRenderer — renders a single element from the spec
// ---------------------------------------------------------------------------
interface ElementRendererInternalProps {
element: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: Component;
}
const ElementRenderer = defineComponent({
name: "JsonRenderElement",
props: {
element: {
type: Object as PropType<UIElement>,
required: true,
},
spec: {
type: Object as PropType<Spec>,
required: true,
},
registry: {
type: Object as PropType<ComponentRegistry>,
required: true,
},
loading: {
type: Boolean,
default: undefined,
},
fallback: {
type: Object as PropType<Component>,
default: undefined,
},
},
setup(props: ElementRendererInternalProps) {
const repeatScope = useRepeatScope();
const { ctx: visibilityCtx } = useVisibility();
const { execute } = useActions();
const { getSnapshot, state: watchState } = useStateStore();
const functions = useFunctions();
// Build context with repeat scope and $computed functions
const fullCtx = computed<PropResolutionContext>(() => {
const base: PropResolutionContext = repeatScope
? {
...visibilityCtx.value,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
}
: { ...visibilityCtx.value };
base.functions = functions.value;
return base;
});
// Create emit function
const emitEvent = async (eventName: string): Promise<void> => {
const binding = props.element.on?.[eventName];
if (!binding) return;
const actionBindings = Array.isArray(binding) ? binding : [binding];
await resolveAndExecuteBindings(
actionBindings,
fullCtx.value,
getSnapshot,
execute,
);
};
// Create on() function
const onEvent = (eventName: string): EventHandle => {
const binding = props.element.on?.[eventName];
if (!binding) {
return { emit: () => {}, shouldPreventDefault: false, bound: false };
}
const actionBindings = Array.isArray(binding) ? binding : [binding];
const shouldPreventDefault = actionBindings.some((b) => b.preventDefault);
return {
emit: () => {
void emitEvent(eventName);
},
shouldPreventDefault,
bound: true,
};
};
// Watch effect: fire actions when watched state paths change.
const watchedValues = computed(() => {
const cfg = props.element.watch;
if (!cfg) return undefined;
const values: Record<string, unknown> = {};
for (const path of Object.keys(cfg)) {
values[path] = getByPath(watchState.value, path);
}
return values;
});
watch(
watchedValues,
(current, prev, onCleanup) => {
const cfg = props.element.watch;
if (!cfg || !current) return;
let cancelled = false;
onCleanup(() => {
cancelled = true;
});
const paths = Object.keys(cfg);
void (async () => {
for (const path of paths) {
if (cancelled) break;
if (prev && current[path] === prev[path]) continue;
const binding = cfg[path];
if (!binding) continue;
const bindings = Array.isArray(binding) ? binding : [binding];
await resolveAndExecuteBindings(
bindings,
fullCtx.value,
getSnapshot,
execute,
() => cancelled,
);
}
})().catch(console.error);
},
{ deep: true },
);
return () => {
const ctx = fullCtx.value;
// Evaluate visibility
const isVisible =
props.element.visible === undefined
? true
: evaluateVisibility(props.element.visible, ctx);
if (!isVisible) return null;
// Resolve bindings and props
const rawProps = props.element.props as Record<string, unknown>;
const elementBindings = resolveBindings(rawProps, ctx);
const resolvedProps = resolveElementProps(rawProps, ctx);
const resolvedElement =
resolvedProps !== props.element.props
? { ...props.element, props: resolvedProps }
: props.element;
// Get component from registry
const Component = props.registry[resolvedElement.type] ?? props.fallback;
if (!Component) {
console.warn(
`[json-render] No renderer for component type: ${resolvedElement.type}`,
);
return null;
}
// Render children
const childrenVNodes: VNode | VNode[] | undefined = resolvedElement.repeat
? h(RepeatChildren, {
element: resolvedElement,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
})
: (resolvedElement.children
?.map((childKey) => {
const childElement = props.spec.elements[childKey];
if (!childElement) {
if (!props.loading) {
console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
);
}
return null;
}
return h(ElementRenderer, {
key: childKey,
element: childElement,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
});
})
.filter((n): n is VNode => n !== null) ?? undefined);
return h(
ElementErrorBoundary,
{ elementType: resolvedElement.type },
{
default: () =>
h(
Component,
{
element: resolvedElement,
emit: emitEvent,
on: onEvent,
bindings: elementBindings,
loading: props.loading,
},
{ default: () => childrenVNodes },
),
},
);
};
},
});
// ---------------------------------------------------------------------------
// RepeatChildren — renders child elements once per item in a state array
// ---------------------------------------------------------------------------
const RepeatChildren = defineComponent({
name: "JsonRenderRepeatChildren",
props: {
element: {
type: Object as PropType<UIElement>,
required: true,
},
spec: {
type: Object as PropType<Spec>,
required: true,
},
registry: {
type: Object as PropType<ComponentRegistry>,
required: true,
},
loading: {
type: Boolean,
default: undefined,
},
fallback: {
type: Object as PropType<Component>,
default: undefined,
},
},
setup(props) {
const { state } = useStateStore();
return () => {
const repeat = props.element.repeat;
if (!repeat?.statePath) return null;
const statePath = repeat.statePath;
const raw = getByPath(state.value, statePath);
const items = Array.isArray(raw) ? (raw as unknown[]) : [];
return items.map((itemValue, index) => {
const key =
repeat.key && typeof itemValue === "object" && itemValue !== null
? String(
(itemValue as Record<string, unknown>)[repeat.key] ?? index,
)
: String(index);
return h(
RepeatScopeProvider,
{ key, item: itemValue, index, basePath: `${statePath}/${index}` },
{
default: () =>
props.element.children
?.map((childKey) => {
const childElement = props.spec.elements[childKey];
if (!childElement) {
if (!props.loading) {
console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${props.element.type}" (repeat). This element will not render.`,
);
}
return null;
}
return h(ElementRenderer, {
key: childKey,
element: childElement,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
});
})
.filter((n): n is VNode => n !== null) ?? null,
},
);
});
};
},
});
// ---------------------------------------------------------------------------
// Renderer — main exported component
// ---------------------------------------------------------------------------
/**
* Main renderer component
*/
export const Renderer = defineComponent({
name: "JsonRenderer",
props: {
spec: {
type: Object as PropType<Spec | null>,
default: null,
},
registry: {
type: Object as PropType<ComponentRegistry>,
required: true,
},
loading: {
type: Boolean,
default: undefined,
},
fallback: {
type: Object as PropType<Component>,
default: undefined,
},
},
setup(props) {
return () => {
if (!props.spec?.root) return null;
const rootElement = props.spec.elements[props.spec.root];
if (!rootElement) return null;
return h(ElementRenderer, {
element: rootElement,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
});
};
},
});
// ---------------------------------------------------------------------------
// ConfirmationDialogManager
// ---------------------------------------------------------------------------
const ConfirmationDialogManager = defineComponent({
name: "ConfirmationDialogManager",
setup() {
const { pendingConfirmation, confirm, cancel } = useActions();
return () => {
if (!pendingConfirmation?.action.confirm) return null;
return h(ConfirmDialog, {
confirm: pendingConfirmation.action.confirm,
onConfirm: confirm,
onCancel: cancel,
});
};
},
});
// ---------------------------------------------------------------------------
// JSONUIProvider — combined provider for all contexts
// ---------------------------------------------------------------------------
/**
* Props for JSONUIProvider
*/
export interface JSONUIProviderProps {
registry: ComponentRegistry;
store?: StateStore;
initialState?: Record<string, unknown>;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
navigate?: (path: string) => void;
validationFunctions?: Record<
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
/**
* Combined provider for all JSONUI contexts
*/
export const JSONUIProvider = defineComponent({
name: "JSONUIProvider",
props: {
registry: {
type: Object as PropType<ComponentRegistry>,
required: true,
},
store: {
type: Object as PropType<StateStore>,
default: undefined,
},
initialState: {
type: Object as PropType<Record<string, unknown>>,
default: undefined,
},
handlers: {
type: Object as PropType<
Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>
>,
default: undefined,
},
navigate: {
type: Function as PropType<(path: string) => void>,
default: undefined,
},
validationFunctions: {
type: Object as PropType<
Record<
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>
>,
default: undefined,
},
functions: {
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
onStateChange: {
type: Function as PropType<
(changes: Array<{ path: string; value: unknown }>) => void
>,
default: undefined,
},
},
setup(props, { slots }) {
return () =>
h(
StateProvider,
{
store: props.store,
initialState: props.initialState,
onStateChange: props.onStateChange,
},
{
default: () =>
h(VisibilityProvider, null, {
default: () =>
h(
ValidationProvider,
{ customFunctions: props.validationFunctions },
{
default: () =>
h(
ActionProvider,
{ handlers: props.handlers, navigate: props.navigate },
{
default: () =>
h(
FunctionsProvider,
{ functions: props.functions },
{
default: () => [
slots.default?.(),
h(ConfirmationDialogManager),
],
},
),
},
),
},
),
}),
},
);
},
});
// ============================================================================
// defineRegistry
// ============================================================================
/**
* Result returned by defineRegistry
*/
export interface DefineRegistryResult {
registry: ComponentRegistry;
handlers: (
getSetState: () => SetState | undefined,
getState: () => StateModel,
) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
executeAction: (
actionName: string,
params: Record<string, unknown> | undefined,
setState: SetState,
state?: StateModel,
) => Promise<void>;
}
type DefineRegistryOptions<C extends Catalog> = {
components?: Components<C>;
} & (CatalogHasActions<C> extends true
? { actions: Actions<C> }
: { actions?: Actions<C> });
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: VNode | VNode[];
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}) => VNode | VNode[] | null | string;
type DefineRegistryActionFn = (
params: Record<string, unknown> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
/**
* Create a registry from a catalog with components and/or actions.
*
* @example
* ```ts
* // Components only
* const { registry } = defineRegistry(catalog, {
* components: {
* Card: ({ props, children }) => h('div', { class: 'card' }, [props.title, children]),
* },
* });
*
* // Both
* const { registry, handlers, executeAction } = defineRegistry(catalog, {
* components: { ... },
* actions: { ... },
* });
* ```
*/
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: DefineRegistryOptions<C>,
): DefineRegistryResult {
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = defineComponent({
name: `JsonRenderRegistry_${name}`,
props: {
element: {
type: Object as PropType<UIElement>,
required: true,
},
emit: {
type: Function as PropType<(event: string) => void>,
required: true,
},
on: {
type: Function as PropType<(event: string) => EventHandle>,
required: true,
},
bindings: {
type: Object as PropType<Record<string, string>>,
default: undefined,
},
loading: {
type: Boolean,
default: undefined,
},
},
setup(registryProps, { slots }) {
return () =>
(componentFn as DefineRegistryComponentFn)({
props: registryProps.element.props,
children: slots.default?.(),
emit: registryProps.emit,
on: registryProps.on,
bindings: registryProps.bindings,
loading: registryProps.loading,
});
},
});
}
}
const actionMap = options.actions
? (Object.entries(options.actions) as Array<
[string, DefineRegistryActionFn]
>)
: [];
const handlers = (
getSetState: () => SetState | undefined,
getState: () => StateModel,
): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
const result: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {};
for (const [name, actionFn] of actionMap) {
result[name] = async (params) => {
const setState = getSetState();
const state = getState();
if (setState) {
await actionFn(params, setState, state);
}
};
}
return result;
};
const executeAction = async (
actionName: string,
params: Record<string, unknown> | undefined,
setState: SetState,
state: StateModel = {},
): Promise<void> => {
const entry = actionMap.find(([name]) => name === actionName);
if (entry) {
await entry[1](params, setState, state);
} else {
console.warn(`Unknown action: ${actionName}`);
}
};
return { registry, handlers, executeAction };
}
// ============================================================================
// createRenderer
// ============================================================================
/**
* Props for renderers created with createRenderer
*/
export interface CreateRendererProps {
spec: Spec | null;
store?: StateStore;
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
loading?: boolean;
fallback?: Component;
}
/**
* Component map type — maps component names to Vue components
*/
export type ComponentMap<
TComponents extends Record<string, { props: unknown }>,
> = {
[K in keyof TComponents]: Component;
};
/**
* Create a renderer from a catalog
*
* @example
* ```typescript
* const DashboardRenderer = createRenderer(dashboardCatalog, {
* Card: ({ props, children }) => h('div', { class: 'card' }, children),
* Metric: ({ props }) => h('span', null, props.value),
* });
*
* // Usage in template
* <DashboardRenderer :spec="aiGeneratedSpec" :state="state" />
* ```
*/
export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record<string, { props: unknown }> },
>(
catalog: Catalog<TDef, TCatalog>,
components: ComponentMap<TCatalog["components"]>,
): Component {
const registry: ComponentRegistry =
components as unknown as ComponentRegistry;
return defineComponent({
name: "CatalogRenderer",
props: {
spec: {
type: Object as PropType<Spec | null>,
default: null,
},
store: {
type: Object as PropType<StateStore>,
default: undefined,
},
state: {
type: Object as PropType<Record<string, unknown>>,
default: undefined,
},
onAction: {
type: Function as PropType<
(actionName: string, params?: Record<string, unknown>) => void
>,
default: undefined,
},
onStateChange: {
type: Function as PropType<
(changes: Array<{ path: string; value: unknown }>) => void
>,
default: undefined,
},
functions: {
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
loading: {
type: Boolean,
default: undefined,
},
fallback: {
type: Object as PropType<Component>,
default: undefined,
},
},
setup(rendererProps) {
return () => {
// Build the action handlers proxy if onAction is provided
const actionHandlers = rendererProps.onAction
? new Proxy(
{} as Record<
string,
(params: Record<string, unknown>) => void | Promise<void>
>,
{
get: (_target, prop: string) => {
return (params: Record<string, unknown>) =>
rendererProps.onAction!(prop, params);
},
has: () => true,
},
)
: undefined;
return h(
StateProvider,
{
store: rendererProps.store,
initialState: rendererProps.state,
onStateChange: rendererProps.onStateChange,
},
{
default: () =>
h(VisibilityProvider, null, {
default: () =>
h(ValidationProvider, null, {
default: () =>
h(
ActionProvider,
{ handlers: actionHandlers },
{
default: () =>
h(
FunctionsProvider,
{ functions: rendererProps.functions },
{
default: () => [
h(Renderer, {
spec: rendererProps.spec,
registry,
loading: rendererProps.loading,
fallback: rendererProps.fallback,
}),
h(ConfirmationDialogManager),
],
},
),
},
),
}),
}),
},
);
};
},
});
}
+109
View File
@@ -0,0 +1,109 @@
import { defineSchema } from "@json-render/core";
/**
* The schema for @json-render/vue
*
* Defines:
* - Spec: A flat tree of elements with keys, types, props, and children references
* - Catalog: Components with props schemas, and optional actions
*/
export const schema = defineSchema(
(s) => ({
// What the AI-generated SPEC looks like
spec: s.object({
/** Root element key */
root: s.string(),
/** Flat map of elements by key */
elements: s.record(
s.object({
/** Component type from catalog */
type: s.ref("catalog.components"),
/** Component props */
props: s.propsOf("catalog.components"),
/** Child element keys (flat reference) */
children: s.array(s.string()),
/** Visibility condition */
visible: s.any(),
}),
),
}),
// What the CATALOG must provide
catalog: s.object({
/** Component definitions */
components: s.map({
/** Zod schema for component props */
props: s.zod(),
/** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */
slots: s.array(s.string()),
/** Description for AI generation hints */
description: s.string(),
/** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
example: s.any(),
}),
/** Action definitions (optional) */
actions: s.map({
/** Zod schema for action params */
params: s.zod(),
/** Description for AI generation hints */
description: s.string(),
}),
}),
}),
{
builtInActions: [
{
name: "setState",
description:
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
},
{
name: "pushState",
description:
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
},
{
name: "removeState",
description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
},
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
},
],
defaultRules: [
// Element integrity
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
// Field placement
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
// State and data
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
// Design quality
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
"For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.",
"Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.",
],
},
);
/**
* Type for the Vue schema
*/
export type VueSchema = typeof schema;
/**
* Infer the spec type from a catalog
*/
export type VueSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/schema.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: ["vue", "@json-render/core"],
});
+43
View File
@@ -0,0 +1,43 @@
# @json-render/xstate-store
[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
```
> [!NOTE]
> This adapter requires `@xstate/store` v3+.
## Usage
```ts
import { createAtom } from "@xstate/store";
import { xstateStoreStateStore } from "@json-render/xstate-store";
import { StateProvider } from "@json-render/react";
// 1. Create an atom
const uiAtom = createAtom({ count: 0 });
// 2. Create the json-render StateStore adapter
const store = xstateStoreStateStore({ atom: uiAtom });
// 3. Use it
<StateProvider store={store}>
{/* json-render reads/writes go through @xstate/store */}
</StateProvider>
```
## API
### `xstateStoreStateStore(options)`
Creates a `StateStore` backed by an `@xstate/store` atom.
#### Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `atom` | `Atom<StateModel>` | Yes | An `@xstate/store` atom (from `createAtom`) holding the json-render state model |
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@json-render/xstate-store",
"version": "0.0.0",
"license": "Apache-2.0",
"description": "XState Store adapter for json-render StateStore",
"keywords": [
"json-render",
"xstate",
"xstate-store",
"state-management",
"adapter"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/xstate-store"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"peerDependencies": {
"@xstate/store": ">=3.0.0"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"@xstate/store": "^3.0.0"
}
}
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect, vi } from "vitest";
import { createAtom } from "@xstate/store";
import { xstateStoreStateStore } from "./index";
function createTestStore(initial: Record<string, unknown> = {}) {
const atom = createAtom<Record<string, unknown>>(initial);
const store = xstateStoreStateStore({ atom });
return { atom, store };
}
describe("xstateStoreStateStore", () => {
it("get/set round-trip", () => {
const { store } = createTestStore({ count: 0 });
expect(store.get("/count")).toBe(0);
store.set("/count", 42);
expect(store.get("/count")).toBe(42);
expect(store.getSnapshot().count).toBe(42);
});
it("update round-trip with multiple values", () => {
const { store } = createTestStore({});
store.update({ "/a": 1, "/b": "hello" });
expect(store.get("/a")).toBe(1);
expect(store.get("/b")).toBe("hello");
expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" });
});
it("subscribe fires on set", () => {
const { store } = createTestStore({});
const listener = vi.fn();
store.subscribe(listener);
store.set("/x", 1);
expect(listener).toHaveBeenCalledTimes(1);
});
it("subscribe fires on update", () => {
const { store } = createTestStore({});
const listener = vi.fn();
store.subscribe(listener);
store.update({ "/a": 1, "/b": 2 });
expect(listener).toHaveBeenCalledTimes(1);
});
it("unsubscribe stops notifications", () => {
const { store } = createTestStore({});
const listener = vi.fn();
const unsub = store.subscribe(listener);
store.set("/x", 1);
expect(listener).toHaveBeenCalledTimes(1);
unsub();
store.set("/x", 2);
expect(listener).toHaveBeenCalledTimes(1);
});
it("getSnapshot immutability -- previous snapshot is not mutated", () => {
const { store } = createTestStore({
user: { name: "Alice", age: 30 },
});
const snap1 = store.getSnapshot();
store.set("/user/name", "Bob");
const snap2 = store.getSnapshot();
expect(snap1.user).toEqual({ name: "Alice", age: 30 });
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
expect(snap1.user).not.toBe(snap2.user);
});
it("structural sharing -- untouched branches keep references", () => {
const { store } = createTestStore({
a: { x: 1 },
b: { y: 2 },
});
const snap1 = store.getSnapshot();
store.set("/a/x", 99);
const snap2 = store.getSnapshot();
expect(snap2.b).toBe(snap1.b);
expect(snap2.a).not.toBe(snap1.a);
});
it("getServerSnapshot returns same as getSnapshot", () => {
const { store } = createTestStore({ x: 1 });
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
store.set("/x", 2);
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
});
it("set skips update when value is unchanged", () => {
const { store } = createTestStore({ x: 1 });
const snap1 = store.getSnapshot();
const listener = vi.fn();
store.subscribe(listener);
store.set("/x", 1);
expect(listener).not.toHaveBeenCalled();
expect(store.getSnapshot()).toBe(snap1);
});
it("update skips update when no values changed", () => {
const { store } = createTestStore({ a: 1, b: 2 });
const snap1 = store.getSnapshot();
const listener = vi.fn();
store.subscribe(listener);
store.update({ "/a": 1, "/b": 2 });
expect(listener).not.toHaveBeenCalled();
expect(store.getSnapshot()).toBe(snap1);
});
it("reads from the shared atom", () => {
const atom = createAtom<Record<string, unknown>>({ count: 0 });
const store = xstateStoreStateStore({ atom });
atom.set({ count: 99 });
expect(store.get("/count")).toBe(99);
expect(store.getSnapshot().count).toBe(99);
});
it("subscribe fires on external atom.set", () => {
const atom = createAtom<Record<string, unknown>>({ count: 0 });
const store = xstateStoreStateStore({ atom });
const listener = vi.fn();
store.subscribe(listener);
atom.set({ count: 99 });
expect(listener).toHaveBeenCalledTimes(1);
expect(store.get("/count")).toBe(99);
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { StateModel, StateStore } from "@json-render/core";
import { createStoreAdapter } from "@json-render/core/store-utils";
import type { Atom } from "@xstate/store";
export type { StateStore } from "@json-render/core";
/**
* Options for {@link xstateStoreStateStore}.
*/
export interface XstateStoreStateStoreOptions {
/** An `@xstate/store` atom (created with `createAtom`). */
atom: Atom<StateModel>;
}
/**
* Create a {@link StateStore} backed by an `@xstate/store` atom.
*
* @example
* ```ts
* import { createAtom } from "@xstate/store";
* import { xstateStoreStateStore } from "@json-render/xstate-store";
*
* const uiAtom = createAtom<Record<string, unknown>>({ count: 0 });
*
* const store = xstateStoreStateStore({ atom: uiAtom });
*
* <StateProvider store={store}>...</StateProvider>
* ```
*/
export function xstateStoreStateStore(
options: XstateStoreStateStoreOptions,
): StateStore {
const { atom } = options;
return createStoreAdapter({
getSnapshot: () => atom.get(),
setSnapshot: (next) => atom.set(next),
subscribe(listener) {
const sub = atom.subscribe(() => {
listener();
});
return () => sub.unsubscribe();
},
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: [
"@json-render/core",
"@json-render/core/store-utils",
"@xstate/store",
],
});
+686 -20
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -3,11 +3,12 @@ import path from "path";
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
// Deduplicate React so tests don't get two copies // Deduplicate React and Vue so tests don't get two copies
// (pnpm strict resolution can cause packages/react to resolve a different copy) // (pnpm strict resolution can cause packages to resolve different copies)
alias: { alias: {
react: path.resolve(__dirname, "node_modules/react"), react: path.resolve(__dirname, "node_modules/react"),
"react-dom": path.resolve(__dirname, "node_modules/react-dom"), "react-dom": path.resolve(__dirname, "node_modules/react-dom"),
vue: path.resolve(__dirname, "packages/vue/node_modules/vue"),
}, },
}, },
test: { test: {