mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc38be8efe | ||
|
|
aa7eef1aea |
@@ -13,9 +13,7 @@
|
||||
"@json-render/codegen",
|
||||
"@json-render/zustand",
|
||||
"@json-render/redux",
|
||||
"@json-render/jotai",
|
||||
"@json-render/vue",
|
||||
"@json-render/xstate"
|
||||
"@json-render/jotai"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -23,6 +23,8 @@ coverage
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
.nuxt/
|
||||
.output/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
|
||||
@@ -5,18 +5,15 @@
|
||||
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
|
||||
```bash
|
||||
# for React
|
||||
npm install @json-render/core @json-render/react
|
||||
# for React with pre-built shadcn/ui components
|
||||
# pre-built shadcn/ui components
|
||||
npm install @json-render/shadcn
|
||||
# or for React Native
|
||||
# or for mobile
|
||||
npm install @json-render/core @json-render/react-native
|
||||
# or for video
|
||||
npm install @json-render/core @json-render/remotion
|
||||
# or for PDF documents
|
||||
npm install @json-render/core @json-render/react-pdf
|
||||
# or for Vue
|
||||
npm install @json-render/core @json-render/vue
|
||||
```
|
||||
|
||||
## Why json-render?
|
||||
@@ -26,7 +23,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
|
||||
- **Guardrailed** - AI can only use components in your catalog
|
||||
- **Predictable** - JSON output matches your schema, every time
|
||||
- **Fast** - Stream and render progressively as the model responds
|
||||
- **Cross-Platform** - React, Vue (web), React Native (mobile) from the same catalog
|
||||
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
|
||||
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
|
||||
|
||||
## Quick Start
|
||||
@@ -113,7 +110,6 @@ function Dashboard({ spec }) {
|
||||
|---------|-------------|
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||
| `@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/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
@@ -121,7 +117,7 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
|
||||
| `@json-render/xstate-store` | XState Store (atom) adapter for `StateStore` |
|
||||
|
||||
## Renderers
|
||||
|
||||
@@ -153,26 +149,6 @@ const { registry } = defineRegistry(catalog, { components });
|
||||
<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)
|
||||
|
||||
```tsx
|
||||
@@ -342,12 +318,10 @@ Any prop value can be data-driven using expressions:
|
||||
}
|
||||
```
|
||||
|
||||
Expression forms:
|
||||
Two expression forms:
|
||||
|
||||
- **`{ "$state": "/state/key" }`** - reads a value from the state model
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition and picks a branch
|
||||
- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates state values into strings
|
||||
- **`{ "$computed": "fn", "args": { ... } }`** - calls a registered function with resolved args
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch
|
||||
|
||||
### Actions
|
||||
|
||||
@@ -363,22 +337,6 @@ Components can trigger actions, including the built-in `setState` action:
|
||||
|
||||
The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
|
||||
|
||||
### State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
|
||||
"watch": {
|
||||
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render.
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
@@ -394,8 +352,6 @@ pnpm dev
|
||||
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
|
||||
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
|
||||
- Chat Example: run `pnpm dev` in `examples/chat`
|
||||
- Vue Example: run `pnpm dev` in `examples/vue`
|
||||
- Vite Renderers (React + Vue): run `pnpm dev` in `examples/vite-renderers`
|
||||
- React Native example: run `npx expo start` in `examples/react-native`
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# web
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/codegen@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -109,27 +109,6 @@ const { registry } = defineRegistry(catalog, {
|
||||
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
||||
```
|
||||
|
||||
### JSONUIProvider
|
||||
|
||||
Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `functions` | `Record<string, ComputedFunction>` | Named functions for `$computed` expressions in props |
|
||||
|
||||
```tsx
|
||||
<JSONUIProvider
|
||||
spec={spec}
|
||||
catalog={catalog}
|
||||
handlers={{ submit: async () => { /* ... */ } }}
|
||||
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
The `functions` prop is also available on `createRenderer`.
|
||||
|
||||
### Component Props (via defineRegistry)
|
||||
|
||||
```tsx
|
||||
@@ -258,15 +237,6 @@ const {
|
||||
|
||||
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
|
||||
|
||||
### useOptionalValidation
|
||||
|
||||
Non-throwing variant of `useValidation()`. Returns `null` when no `ValidationProvider` is present, instead of throwing. Useful in components that may or may not be rendered inside a validation context.
|
||||
|
||||
```typescript
|
||||
const validation = useOptionalValidation();
|
||||
// ValidationContextValue | null
|
||||
```
|
||||
|
||||
### useBoundProp
|
||||
|
||||
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
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 |
|
||||
@@ -5,119 +5,6 @@ export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.10.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: `@json-render/vue`
|
||||
|
||||
Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/vue
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { h } from "vue";
|
||||
import { defineRegistry, Renderer } from "@json-render/vue";
|
||||
import { schema } from "@json-render/vue/schema";
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "card" }, [h("h3", null, props.title), children]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`.
|
||||
|
||||
See the [Vue API reference](/docs/api/vue) for details.
|
||||
|
||||
### New: `@json-render/xstate`
|
||||
|
||||
[XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer.
|
||||
|
||||
```bash
|
||||
npm install @json-render/xstate @xstate/store
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { createAtom } from "@xstate/store";
|
||||
import { xstateStoreStateStore } from "@json-render/xstate";
|
||||
|
||||
const atom = createAtom({ count: 0 });
|
||||
const store = xstateStoreStateStore({ atom });
|
||||
```
|
||||
|
||||
Requires `@xstate/store` v3+.
|
||||
|
||||
### New: `$computed` and `$template` Expressions
|
||||
|
||||
Two new prop expression types for dynamic values:
|
||||
|
||||
- **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }`
|
||||
- **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }`
|
||||
|
||||
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details.
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
|
||||
"watch": {
|
||||
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details.
|
||||
|
||||
### New: Cross-Field Validation
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
|
||||
- `equalTo` -- alias for `matches` with clearer semantics
|
||||
- `lessThan` -- value must be less than another field
|
||||
- `greaterThan` -- value must be greater than another field
|
||||
- `requiredIf` -- required only when a condition field is truthy
|
||||
|
||||
Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently.
|
||||
|
||||
### New: `validateForm` Action
|
||||
|
||||
Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state:
|
||||
|
||||
```json
|
||||
{
|
||||
"on": {
|
||||
"press": [
|
||||
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||
{ "action": "submitForm" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support `checks` and `validateOn` props:
|
||||
- Checkbox, Radio, Switch added validation support
|
||||
- `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"`
|
||||
|
||||
### New Examples
|
||||
|
||||
- **Vue example** -- standalone Vue 3 app with custom components
|
||||
- **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog
|
||||
|
||||
---
|
||||
|
||||
## v0.9.1
|
||||
|
||||
February 2026
|
||||
|
||||
@@ -9,18 +9,6 @@ Install the core package plus your renderer of choice.
|
||||
|
||||
<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
|
||||
|
||||
Pre-built components for fast prototyping and production use:
|
||||
@@ -47,10 +35,17 @@ If you want to wire json-render to an existing state management library instead
|
||||
|
||||
<PackageInstall packages="@json-render/jotai" />
|
||||
|
||||
<PackageInstall packages="@json-render/xstate" />
|
||||
|
||||
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
|
||||
|
||||
To use json-render with AI models, you'll also need the Vercel AI SDK:
|
||||
|
||||
@@ -69,16 +69,6 @@ export const docsNavigation: NavSection[] = [
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -107,7 +97,6 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -23,9 +23,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/streaming": "Streaming",
|
||||
"docs/validation": "Validation",
|
||||
"docs/data-binding": "Data Binding",
|
||||
"docs/computed-values": "Computed Values",
|
||||
"docs/visibility": "Visibility",
|
||||
"docs/watchers": "Watchers",
|
||||
"docs/generation-modes": "Generation Modes",
|
||||
"docs/code-export": "Code Export",
|
||||
"docs/custom-schema": "Custom Schema & Renderer",
|
||||
@@ -40,7 +38,6 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
// API references
|
||||
"docs/api/core": "@json-render/core 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-native": "@json-render/react-native API",
|
||||
"docs/api/codegen": "@json-render/codegen API",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# example-chat
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/shadcn@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# example-dashboard
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/codegen@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# example-no-ai
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/shadcn@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-no-ai",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export interface Example {
|
||||
name: string;
|
||||
description: string;
|
||||
spec: Spec;
|
||||
}
|
||||
|
||||
export const examples: Example[] = [
|
||||
{
|
||||
name: "User Profile Card",
|
||||
description: "A card with user info, badges, and a progress bar",
|
||||
spec: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "User Profile",
|
||||
description: null,
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["stack"],
|
||||
},
|
||||
stack: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: ["heading", "text", "badges", "sep", "progress"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Jane Cooper", level: "h2" },
|
||||
},
|
||||
text: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
|
||||
variant: "muted",
|
||||
},
|
||||
},
|
||||
badges: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: ["b1", "b2", "b3"],
|
||||
},
|
||||
b1: {
|
||||
type: "Badge",
|
||||
props: { text: "TypeScript", variant: "default" },
|
||||
},
|
||||
b2: { type: "Badge", props: { text: "Vue", variant: "secondary" } },
|
||||
b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
|
||||
sep: { type: "Separator", props: { orientation: null } },
|
||||
progress: {
|
||||
type: "Progress",
|
||||
props: { value: 72, max: 100, label: "Profile completion" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Settings Form",
|
||||
description: "A form with inputs, selects, and switches",
|
||||
spec: {
|
||||
root: "card",
|
||||
state: {
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.com",
|
||||
role: "Engineer",
|
||||
notifications: true,
|
||||
darkMode: false,
|
||||
},
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Account Settings",
|
||||
description: "Manage your preferences",
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["form"],
|
||||
},
|
||||
form: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: [
|
||||
"nameInput",
|
||||
"emailInput",
|
||||
"roleSelect",
|
||||
"sep",
|
||||
"notifSwitch",
|
||||
"darkSwitch",
|
||||
"sep2",
|
||||
"actions",
|
||||
],
|
||||
},
|
||||
nameInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Full Name",
|
||||
name: "name",
|
||||
type: "text",
|
||||
placeholder: "Your name",
|
||||
value: { $bindState: "/name" },
|
||||
checks: null,
|
||||
},
|
||||
},
|
||||
emailInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
placeholder: "you@example.com",
|
||||
value: { $bindState: "/email" },
|
||||
checks: null,
|
||||
},
|
||||
},
|
||||
roleSelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Role",
|
||||
name: "role",
|
||||
options: [
|
||||
"Engineer",
|
||||
"Designer",
|
||||
"Product Manager",
|
||||
"Data Scientist",
|
||||
],
|
||||
placeholder: "Choose a role",
|
||||
value: { $bindState: "/role" },
|
||||
checks: null,
|
||||
},
|
||||
},
|
||||
sep: { type: "Separator", props: { orientation: null } },
|
||||
notifSwitch: {
|
||||
type: "Switch",
|
||||
props: {
|
||||
label: "Email notifications",
|
||||
name: "notifications",
|
||||
checked: { $bindState: "/notifications" },
|
||||
},
|
||||
},
|
||||
darkSwitch: {
|
||||
type: "Switch",
|
||||
props: {
|
||||
label: "Dark mode",
|
||||
name: "darkMode",
|
||||
checked: { $bindState: "/darkMode" },
|
||||
},
|
||||
},
|
||||
sep2: { type: "Separator", props: { orientation: null } },
|
||||
actions: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: "end",
|
||||
},
|
||||
children: ["cancelBtn", "saveBtn"],
|
||||
},
|
||||
cancelBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Cancel", variant: "secondary", disabled: null },
|
||||
},
|
||||
saveBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Save Changes", variant: "primary", disabled: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Pricing Table",
|
||||
description: "A grid of pricing cards with feature lists",
|
||||
spec: {
|
||||
root: "outer",
|
||||
elements: {
|
||||
outer: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "lg",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: ["header", "grid"],
|
||||
},
|
||||
header: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "sm",
|
||||
align: "center",
|
||||
justify: null,
|
||||
},
|
||||
children: ["title", "subtitle"],
|
||||
},
|
||||
title: {
|
||||
type: "Heading",
|
||||
props: { text: "Simple, transparent pricing", level: "h1" },
|
||||
},
|
||||
subtitle: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
|
||||
variant: "muted",
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
type: "Grid",
|
||||
props: { columns: 3, gap: "md" },
|
||||
children: ["free", "pro", "enterprise"],
|
||||
},
|
||||
free: {
|
||||
type: "Card",
|
||||
props: { title: "Free", description: "$0/month" },
|
||||
children: ["freeContent"],
|
||||
},
|
||||
freeContent: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "sm" },
|
||||
children: ["f1", "f2", "f3", "freeBtn"],
|
||||
},
|
||||
f1: {
|
||||
type: "Text",
|
||||
props: { text: "Up to 3 projects", variant: "body" },
|
||||
},
|
||||
f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
|
||||
f3: {
|
||||
type: "Text",
|
||||
props: { text: "Community support", variant: "body" },
|
||||
},
|
||||
freeBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Get Started", variant: "secondary" },
|
||||
},
|
||||
pro: {
|
||||
type: "Card",
|
||||
props: { title: "Pro", description: "$19/month" },
|
||||
children: ["proContent"],
|
||||
},
|
||||
proContent: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "sm" },
|
||||
children: ["p1", "p2", "p3", "p4", "proBtn"],
|
||||
},
|
||||
p1: {
|
||||
type: "Text",
|
||||
props: { text: "Unlimited projects", variant: "body" },
|
||||
},
|
||||
p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
|
||||
p3: {
|
||||
type: "Text",
|
||||
props: { text: "Priority support", variant: "body" },
|
||||
},
|
||||
p4: {
|
||||
type: "Text",
|
||||
props: { text: "Custom domains", variant: "body" },
|
||||
},
|
||||
proBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Upgrade to Pro", variant: "primary" },
|
||||
},
|
||||
enterprise: {
|
||||
type: "Card",
|
||||
props: { title: "Enterprise", description: "Custom pricing" },
|
||||
children: ["entContent"],
|
||||
},
|
||||
entContent: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "sm" },
|
||||
children: ["e1", "e2", "e3", "e4", "entBtn"],
|
||||
},
|
||||
e1: {
|
||||
type: "Text",
|
||||
props: { text: "Everything in Pro", variant: "body" },
|
||||
},
|
||||
e2: {
|
||||
type: "Text",
|
||||
props: { text: "Unlimited storage", variant: "body" },
|
||||
},
|
||||
e3: {
|
||||
type: "Text",
|
||||
props: { text: "Dedicated support", variant: "body" },
|
||||
},
|
||||
e4: {
|
||||
type: "Text",
|
||||
props: { text: "SLA guarantees", variant: "body" },
|
||||
},
|
||||
entBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Contact Sales", variant: "secondary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Status Dashboard",
|
||||
description: "System status with alerts, a table, and an accordion",
|
||||
spec: {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "lg" },
|
||||
children: [
|
||||
"heading",
|
||||
"alertOk",
|
||||
"alertWarn",
|
||||
"sep",
|
||||
"table",
|
||||
"sep2",
|
||||
"accordion",
|
||||
],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "System Status", level: "h1" },
|
||||
},
|
||||
alertOk: {
|
||||
type: "Alert",
|
||||
props: {
|
||||
title: "API",
|
||||
message: "All systems operational -- 99.98% uptime this month",
|
||||
type: "success",
|
||||
},
|
||||
},
|
||||
alertWarn: {
|
||||
type: "Alert",
|
||||
props: {
|
||||
title: "Database",
|
||||
message: "Elevated latency detected in us-east-1 region",
|
||||
type: "warning",
|
||||
},
|
||||
},
|
||||
sep: { type: "Separator", props: { orientation: null } },
|
||||
table: {
|
||||
type: "Table",
|
||||
props: {
|
||||
columns: ["Service", "Status", "Latency", "Uptime"],
|
||||
rows: [
|
||||
["API Gateway", "Operational", "12ms", "99.99%"],
|
||||
["Auth Service", "Operational", "8ms", "99.98%"],
|
||||
["Database", "Degraded", "145ms", "99.85%"],
|
||||
["CDN", "Operational", "3ms", "100%"],
|
||||
["Workers", "Operational", "22ms", "99.97%"],
|
||||
],
|
||||
caption: "Last updated 2 minutes ago",
|
||||
},
|
||||
},
|
||||
sep2: { type: "Separator", props: { orientation: null } },
|
||||
accordion: {
|
||||
type: "Accordion",
|
||||
props: {
|
||||
items: [
|
||||
{
|
||||
title: "What does 'Degraded' mean?",
|
||||
content:
|
||||
"The service is still operational but experiencing slower response times than normal. We are actively investigating.",
|
||||
},
|
||||
{
|
||||
title: "How is uptime calculated?",
|
||||
content:
|
||||
"Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
|
||||
},
|
||||
{
|
||||
title: "How do I subscribe to updates?",
|
||||
content:
|
||||
"You can subscribe to incident notifications via email or webhook on the Settings page.",
|
||||
},
|
||||
],
|
||||
type: "single",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,173 @@
|
||||
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().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
maxWidth: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
|
||||
centered: z.boolean().nullable().optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "A card container with optional title and description",
|
||||
},
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).optional(),
|
||||
gap: z.enum(["sm", "md", "lg"]).optional(),
|
||||
align: z
|
||||
.enum(["start", "center", "end", "stretch"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
justify: z
|
||||
.enum(["start", "center", "end", "between", "around"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Flex container for laying out children horizontally or vertically",
|
||||
},
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z.number().optional(),
|
||||
gap: z.enum(["sm", "md", "lg"]).optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Grid layout container",
|
||||
},
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Heading text element",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["body", "muted", "bold"]).optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Text paragraph element",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z
|
||||
.enum(["primary", "secondary", "outline", "danger"])
|
||||
.optional(),
|
||||
disabled: z.boolean().nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Clickable button. Emits 'press' event.",
|
||||
},
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string().optional(),
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).optional(),
|
||||
placeholder: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
checks: z.any().nullable().optional(),
|
||||
validateOn: z.string().nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Text input field with optional label. Emits 'change' event.",
|
||||
},
|
||||
Select: {
|
||||
props: z.object({
|
||||
label: z.string().optional(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
checks: z.any().nullable().optional(),
|
||||
validateOn: z.string().nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Dropdown select field. Emits 'change' event.",
|
||||
},
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Toggle switch. Emits 'change' event.",
|
||||
},
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "outline", "destructive"])
|
||||
.optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Small status badge label",
|
||||
},
|
||||
Separator: {
|
||||
props: z.object({
|
||||
orientation: z.enum(["horizontal", "vertical"]).nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Visual separator line",
|
||||
},
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Progress bar",
|
||||
},
|
||||
Alert: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
type: z.enum(["info", "success", "warning", "error"]).optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Alert / notification banner",
|
||||
},
|
||||
Table: {
|
||||
props: z.object({
|
||||
columns: z.array(z.string()),
|
||||
rows: z.array(z.array(z.string())),
|
||||
caption: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Data table with columns and rows",
|
||||
},
|
||||
Accordion: {
|
||||
props: z.object({
|
||||
items: z.array(z.object({ title: z.string(), content: z.string() })),
|
||||
type: z.enum(["single", "multiple"]).optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Collapsible accordion sections",
|
||||
},
|
||||
Radio: {
|
||||
props: z.object({
|
||||
label: z.string().optional(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
value: z.string().optional(),
|
||||
checks: z.any().nullable().optional(),
|
||||
validateOn: z.string().nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Radio button group. Emits 'change' event.",
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
// Cast needed: zod schema types don't align with the inferred catalog input
|
||||
// type from defineCatalog's generics when no actions are defined.
|
||||
} as any);
|
||||
@@ -0,0 +1,593 @@
|
||||
import { h, ref, defineComponent, type VNode, type PropType } from "vue";
|
||||
import {
|
||||
defineRegistry,
|
||||
useBoundProp,
|
||||
useFieldValidation,
|
||||
type BaseComponentProps,
|
||||
} from "@json-render/vue";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
type Ctx<P = Record<string, unknown>> = BaseComponentProps<P>;
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({
|
||||
props,
|
||||
children,
|
||||
}: Ctx<{
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | null;
|
||||
centered?: boolean | null;
|
||||
}>) => {
|
||||
const maxWidthClass =
|
||||
props.maxWidth === "sm"
|
||||
? "max-w-sm"
|
||||
: props.maxWidth === "md"
|
||||
? "max-w-md"
|
||||
: props.maxWidth === "lg"
|
||||
? "max-w-lg"
|
||||
: props.maxWidth === "xl"
|
||||
? "max-w-xl"
|
||||
: "";
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: [
|
||||
"rounded-lg border bg-white shadow-sm p-6",
|
||||
maxWidthClass,
|
||||
props.centered ? "mx-auto" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
[
|
||||
props.title &&
|
||||
h("h3", { class: "text-lg font-semibold" }, props.title),
|
||||
props.description &&
|
||||
h("p", { class: "text-sm text-gray-500 mt-1" }, props.description),
|
||||
(props.title || props.description) &&
|
||||
h("div", { class: "mt-4" }, children),
|
||||
!(props.title || props.description) && children,
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
Stack: ({
|
||||
props,
|
||||
children,
|
||||
}: Ctx<{
|
||||
direction?: "horizontal" | "vertical";
|
||||
gap?: "sm" | "md" | "lg";
|
||||
align?: "start" | "center" | "end" | "stretch" | null;
|
||||
justify?: "start" | "center" | "end" | "between" | "around" | null;
|
||||
}>) => {
|
||||
const gapClass =
|
||||
props.gap === "sm" ? "gap-2" : props.gap === "lg" ? "gap-6" : "gap-4";
|
||||
const dirClass =
|
||||
props.direction === "horizontal" ? "flex-row" : "flex-col";
|
||||
const alignMap: Record<string, string> = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
};
|
||||
const justifyMap: Record<string, string> = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
between: "justify-between",
|
||||
around: "justify-around",
|
||||
};
|
||||
const alignClass = props.align ? (alignMap[props.align] ?? "") : "";
|
||||
const justifyClass = props.justify
|
||||
? (justifyMap[props.justify] ?? "")
|
||||
: "";
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: ["flex", dirClass, gapClass, alignClass, justifyClass]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
|
||||
Grid: ({
|
||||
props,
|
||||
children,
|
||||
}: Ctx<{ columns?: number; gap?: "sm" | "md" | "lg" }>) => {
|
||||
const cols = props.columns ?? 3;
|
||||
const gapClass =
|
||||
props.gap === "sm" ? "gap-2" : props.gap === "lg" ? "gap-6" : "gap-4";
|
||||
const colClass =
|
||||
cols === 1
|
||||
? "grid-cols-1"
|
||||
: cols === 2
|
||||
? "grid-cols-2"
|
||||
: cols === 3
|
||||
? "grid-cols-3"
|
||||
: cols === 4
|
||||
? "grid-cols-4"
|
||||
: `grid-cols-${cols}`;
|
||||
return h("div", { class: `grid ${colClass} ${gapClass}` }, children);
|
||||
},
|
||||
|
||||
Heading: ({
|
||||
props,
|
||||
}: Ctx<{ text: string; level?: "h1" | "h2" | "h3" | "h4" }>) => {
|
||||
const tag = props.level ?? "h2";
|
||||
const sizeClass =
|
||||
tag === "h1"
|
||||
? "text-3xl font-bold"
|
||||
: tag === "h2"
|
||||
? "text-2xl font-semibold"
|
||||
: tag === "h3"
|
||||
? "text-xl font-semibold"
|
||||
: "text-lg font-medium";
|
||||
return h(tag, { class: sizeClass }, props.text);
|
||||
},
|
||||
|
||||
Text: ({
|
||||
props,
|
||||
}: Ctx<{ text: string; variant?: "body" | "muted" | "bold" }>) => {
|
||||
const variantClass =
|
||||
props.variant === "muted"
|
||||
? "text-gray-500"
|
||||
: props.variant === "bold"
|
||||
? "font-semibold"
|
||||
: "";
|
||||
return h("p", { class: variantClass }, props.text);
|
||||
},
|
||||
|
||||
Button: ({
|
||||
props,
|
||||
emit,
|
||||
}: Ctx<{
|
||||
label: string;
|
||||
variant?: "primary" | "secondary" | "outline" | "danger";
|
||||
disabled?: boolean | null;
|
||||
}>) => {
|
||||
const baseClass =
|
||||
"inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
|
||||
const variantClass =
|
||||
props.variant === "primary"
|
||||
? "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500"
|
||||
: props.variant === "danger"
|
||||
? "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500"
|
||||
: props.variant === "outline"
|
||||
? "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500";
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
class: `${baseClass} ${variantClass}`,
|
||||
disabled: props.disabled ?? false,
|
||||
onClick: () => emit("press"),
|
||||
},
|
||||
props.label,
|
||||
);
|
||||
},
|
||||
|
||||
Input: ({
|
||||
props,
|
||||
bindings,
|
||||
emit,
|
||||
}: Ctx<{
|
||||
label?: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
checks?: unknown;
|
||||
validateOn?: string | null;
|
||||
}>) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value,
|
||||
bindings?.value,
|
||||
);
|
||||
const validation = props.checks
|
||||
? useFieldValidation(`/${props.name}`, {
|
||||
checks: props.checks as any[],
|
||||
validateOn: (props.validateOn as "blur" | "change") ?? undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ class: "flex flex-col gap-1.5" },
|
||||
[
|
||||
props.label &&
|
||||
h(
|
||||
"label",
|
||||
{ class: "text-sm font-medium text-gray-700" },
|
||||
props.label,
|
||||
),
|
||||
h("input", {
|
||||
type: props.type ?? "text",
|
||||
placeholder: props.placeholder,
|
||||
value: value ?? "",
|
||||
class:
|
||||
"rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
|
||||
onInput: (e: Event) => {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
setValue(val);
|
||||
emit("change");
|
||||
if (validation && props.validateOn === "change") {
|
||||
validation.validate();
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
if (validation && props.validateOn === "blur") {
|
||||
validation.touch();
|
||||
validation.validate();
|
||||
}
|
||||
},
|
||||
}),
|
||||
validation &&
|
||||
validation.errors.value.length > 0 &&
|
||||
h(
|
||||
"p",
|
||||
{ class: "text-xs text-red-500" },
|
||||
validation.errors.value[0],
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
Select: ({
|
||||
props,
|
||||
bindings,
|
||||
emit,
|
||||
}: Ctx<{
|
||||
label?: string;
|
||||
name: string;
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
checks?: unknown;
|
||||
validateOn?: string | null;
|
||||
}>) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value,
|
||||
bindings?.value,
|
||||
);
|
||||
const options = props.options ?? [];
|
||||
const validation = props.checks
|
||||
? useFieldValidation(`/${props.name}`, {
|
||||
checks: props.checks as any[],
|
||||
validateOn: (props.validateOn as "blur" | "change") ?? undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ class: "flex flex-col gap-1.5" },
|
||||
[
|
||||
props.label &&
|
||||
h(
|
||||
"label",
|
||||
{ class: "text-sm font-medium text-gray-700" },
|
||||
props.label,
|
||||
),
|
||||
h(
|
||||
"select",
|
||||
{
|
||||
value: value ?? "",
|
||||
class:
|
||||
"rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
|
||||
onChange: (e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
setValue(val);
|
||||
emit("change");
|
||||
if (validation && props.validateOn === "change") {
|
||||
validation.validate();
|
||||
}
|
||||
},
|
||||
},
|
||||
[
|
||||
props.placeholder &&
|
||||
h("option", { value: "", disabled: true }, props.placeholder),
|
||||
...options.map((opt: string) =>
|
||||
h("option", { value: opt, key: opt }, opt),
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
),
|
||||
validation &&
|
||||
validation.errors.value.length > 0 &&
|
||||
h(
|
||||
"p",
|
||||
{ class: "text-xs text-red-500" },
|
||||
validation.errors.value[0],
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
Switch: ({
|
||||
props,
|
||||
bindings,
|
||||
emit,
|
||||
}: Ctx<{ label: string; name: string; checked?: boolean }>) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked,
|
||||
bindings?.checked,
|
||||
);
|
||||
const isOn = checked ?? false;
|
||||
return h("label", { class: "flex items-center gap-3 cursor-pointer" }, [
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
role: "switch",
|
||||
"aria-checked": isOn,
|
||||
class: [
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isOn ? "bg-blue-600" : "bg-gray-200",
|
||||
].join(" "),
|
||||
onClick: () => {
|
||||
setChecked(!isOn);
|
||||
emit("change");
|
||||
},
|
||||
},
|
||||
h("span", {
|
||||
class: [
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
isOn ? "translate-x-6" : "translate-x-1",
|
||||
].join(" "),
|
||||
}),
|
||||
),
|
||||
h("span", { class: "text-sm" }, props.label),
|
||||
]);
|
||||
},
|
||||
|
||||
Badge: ({
|
||||
props,
|
||||
}: Ctx<{
|
||||
text: string;
|
||||
variant?: "default" | "secondary" | "outline" | "destructive";
|
||||
}>) => {
|
||||
const variantClass =
|
||||
props.variant === "secondary"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: props.variant === "outline"
|
||||
? "border border-gray-300 text-gray-700"
|
||||
: props.variant === "destructive"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-blue-100 text-blue-800";
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
class: `inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${variantClass}`,
|
||||
},
|
||||
props.text,
|
||||
);
|
||||
},
|
||||
|
||||
Separator: () => {
|
||||
return h("hr", { class: "border-gray-200" });
|
||||
},
|
||||
|
||||
Progress: ({
|
||||
props,
|
||||
}: Ctx<{ value: number; max?: number; label?: string }>) => {
|
||||
const pct = Math.min(
|
||||
100,
|
||||
Math.round((props.value / (props.max ?? 100)) * 100),
|
||||
);
|
||||
return h(
|
||||
"div",
|
||||
{ class: "flex flex-col gap-1.5" },
|
||||
[
|
||||
props.label &&
|
||||
h("div", { class: "flex justify-between text-sm" }, [
|
||||
h("span", { class: "text-gray-600" }, props.label),
|
||||
h("span", { class: "font-medium" }, `${pct}%`),
|
||||
]),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: "h-2 w-full rounded-full bg-gray-200 overflow-hidden",
|
||||
},
|
||||
h("div", {
|
||||
class: "h-full rounded-full bg-blue-600 transition-all",
|
||||
style: { width: `${pct}%` },
|
||||
}),
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
Alert: ({
|
||||
props,
|
||||
}: Ctx<{
|
||||
title: string;
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
}>) => {
|
||||
const typeClasses: Record<string, string> = {
|
||||
success: "bg-green-50 border-green-200 text-green-800",
|
||||
warning: "bg-yellow-50 border-yellow-200 text-yellow-800",
|
||||
error: "bg-red-50 border-red-200 text-red-800",
|
||||
info: "bg-blue-50 border-blue-200 text-blue-800",
|
||||
};
|
||||
const cls = typeClasses[props.type ?? "info"] ?? typeClasses.info;
|
||||
return h("div", { class: `rounded-md border p-4 ${cls}` }, [
|
||||
h("p", { class: "text-sm font-medium" }, props.title),
|
||||
h("p", { class: "text-sm mt-1 opacity-80" }, props.message),
|
||||
]);
|
||||
},
|
||||
|
||||
Table: ({
|
||||
props,
|
||||
}: Ctx<{
|
||||
columns: string[];
|
||||
rows: string[][];
|
||||
caption?: string;
|
||||
}>) => {
|
||||
return h(
|
||||
"div",
|
||||
{ class: "overflow-x-auto" },
|
||||
[
|
||||
h("table", { class: "min-w-full text-sm" }, [
|
||||
h(
|
||||
"thead",
|
||||
null,
|
||||
h(
|
||||
"tr",
|
||||
{ class: "border-b" },
|
||||
props.columns.map((col: string) =>
|
||||
h(
|
||||
"th",
|
||||
{
|
||||
key: col,
|
||||
class: "px-4 py-2 text-left font-medium text-gray-500",
|
||||
},
|
||||
col,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"tbody",
|
||||
null,
|
||||
props.rows.map((row: string[], i: number) =>
|
||||
h(
|
||||
"tr",
|
||||
{ key: i, class: "border-b last:border-0" },
|
||||
row.map((cell: string, j: number) =>
|
||||
h("td", { key: j, class: "px-4 py-2 text-gray-700" }, cell),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
props.caption &&
|
||||
h("p", { class: "text-xs text-gray-400 mt-2 px-4" }, props.caption),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
// Uses defineComponent instead of a plain render function because it
|
||||
// needs local reactive state (openItems ref) to track expanded items.
|
||||
Accordion: defineComponent({
|
||||
props: {
|
||||
props: {
|
||||
type: Object as PropType<{
|
||||
items: Array<{ title: string; content: string }>;
|
||||
type?: "single" | "multiple";
|
||||
}>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(compProps) {
|
||||
const openItems = ref<Set<number>>(new Set());
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
const next = new Set(openItems.value);
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx);
|
||||
} else {
|
||||
if (compProps.props.type === "single") next.clear();
|
||||
next.add(idx);
|
||||
}
|
||||
openItems.value = next;
|
||||
};
|
||||
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "divide-y border rounded-md" },
|
||||
compProps.props.items.map(
|
||||
(item: { title: string; content: string }, i: number) =>
|
||||
h("div", { key: i }, [
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
class:
|
||||
"flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-left hover:bg-gray-50 transition-colors",
|
||||
onClick: () => toggle(i),
|
||||
},
|
||||
[
|
||||
h("span", null, item.title),
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: [
|
||||
"transform transition-transform text-gray-400",
|
||||
openItems.value.has(i) ? "rotate-180" : "",
|
||||
].join(" "),
|
||||
},
|
||||
"\u25BE",
|
||||
),
|
||||
],
|
||||
),
|
||||
openItems.value.has(i) &&
|
||||
h(
|
||||
"div",
|
||||
{ class: "px-4 pb-3 text-sm text-gray-600" },
|
||||
item.content,
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
}) as any,
|
||||
|
||||
Radio: ({
|
||||
props,
|
||||
bindings,
|
||||
emit,
|
||||
}: Ctx<{
|
||||
label?: string;
|
||||
name: string;
|
||||
options: string[];
|
||||
value?: string;
|
||||
}>) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value,
|
||||
bindings?.value,
|
||||
);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ class: "flex flex-col gap-1.5" },
|
||||
[
|
||||
props.label &&
|
||||
h(
|
||||
"label",
|
||||
{ class: "text-sm font-medium text-gray-700" },
|
||||
props.label,
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex gap-4" },
|
||||
(props.options ?? []).map((opt: string) =>
|
||||
h(
|
||||
"label",
|
||||
{
|
||||
key: opt,
|
||||
class: "flex items-center gap-2 cursor-pointer",
|
||||
},
|
||||
[
|
||||
h("input", {
|
||||
type: "radio",
|
||||
name: props.name,
|
||||
value: opt,
|
||||
checked: value === opt,
|
||||
class: "accent-blue-600",
|
||||
onChange: () => {
|
||||
setValue(opt);
|
||||
emit("change");
|
||||
},
|
||||
}),
|
||||
h("span", { class: "text-sm" }, opt),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-11-01",
|
||||
css: ["~/assets/css/main.css"],
|
||||
vite: {
|
||||
// Tailwind's Vite plugin types don't align with Nuxt's plugin types
|
||||
plugins: [tailwindcss() as any],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "example-nuxt",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless nuxt-demo.json-render nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"check-types": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/vue": "workspace:*",
|
||||
"nuxt": "^4.3.1",
|
||||
"vue": "^3.5.29",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { JSONUIProvider, Renderer } from "@json-render/vue";
|
||||
import { registry } from "~/lib/render/registry";
|
||||
import { examples } from "~/lib/examples";
|
||||
|
||||
const selectedIndex = ref(0);
|
||||
const selected = computed(() => examples[selectedIndex.value]!);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-gray-50">
|
||||
<!-- Example selector -->
|
||||
<nav class="flex gap-1 p-3 overflow-x-auto border-b bg-white shrink-0">
|
||||
<button
|
||||
v-for="(ex, i) in examples"
|
||||
:key="ex.name"
|
||||
class="px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors"
|
||||
:class="
|
||||
i === selectedIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'hover:bg-gray-100 text-gray-700'
|
||||
"
|
||||
@click="selectedIndex = i"
|
||||
>
|
||||
{{ ex.name }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Render area -->
|
||||
<div class="flex-1 flex items-start justify-center overflow-auto p-6">
|
||||
<div
|
||||
class="bg-white border rounded-lg shadow-sm w-full max-w-[960px] p-6"
|
||||
>
|
||||
<p class="text-xs text-gray-400 mb-4">
|
||||
{{ selected.description }}
|
||||
</p>
|
||||
<JSONUIProvider
|
||||
:key="selectedIndex"
|
||||
:registry="registry"
|
||||
:initial-state="selected.spec.state ?? {}"
|
||||
>
|
||||
<Renderer :spec="selected.spec" :registry="registry" />
|
||||
</JSONUIProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
# example-react-native
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react-native@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-native",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# example-react-pdf
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react-pdf@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-pdf",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# example-remotion
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/remotion@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-remotion",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# com.example.json-render-demo
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.3",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# com.example.json-render-fullpage-demo
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.3",
|
||||
"description": "Full-page Stripe App example (alpha)",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# vite-renderers
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/vue@0.10.0
|
||||
@@ -1,31 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "vite-renderers",
|
||||
"version": "0.1.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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");
|
||||
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { catalogDef } from "../shared/catalog-def";
|
||||
|
||||
export const catalog = schema.createCatalog(catalogDef);
|
||||
export type AppCatalog = typeof catalog;
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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>
|
||||
),
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
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" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
/* ---- 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;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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 } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
<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>
|
||||
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
@@ -1,5 +0,0 @@
|
||||
import { schema } from "@json-render/vue/schema";
|
||||
import { catalogDef } from "../shared/catalog-def";
|
||||
|
||||
export const catalog = schema.createCatalog(catalogDef);
|
||||
export type AppCatalog = typeof catalog;
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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",
|
||||
),
|
||||
]),
|
||||
]),
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), react({ include: /\.tsx$/ })],
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
# example-vue
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/vue@0.10.0
|
||||
@@ -1,26 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "example-vue",
|
||||
"version": "0.1.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<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>
|
||||
@@ -1,68 +0,0 @@
|
||||
<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>
|
||||
@@ -1,92 +0,0 @@
|
||||
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;
|
||||
@@ -1,213 +0,0 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
@@ -30,6 +30,7 @@
|
||||
"@changesets/cli": "2.29.8",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/react": "^19.2.3",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
@@ -39,6 +40,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"turbo": "^2.7.4",
|
||||
"typescript": "5.9.2",
|
||||
"vue": "^3.5.29",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# @json-render/codegen
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,76 +1,5 @@
|
||||
# @json-render/core
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
## 0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
@@ -188,22 +188,6 @@ Schema options:
|
||||
| `resolvePropValue(value, ctx)` | Resolve a single prop expression |
|
||||
| `resolveElementProps(props, ctx)` | Resolve all prop expressions in an element |
|
||||
| `PropExpression<T>` | Type for prop values that may contain expressions |
|
||||
| `ComputedFunction` | Function signature for `$computed` expressions |
|
||||
| `PropResolutionContext` | Context for resolving props (includes `functions` for `$computed`) |
|
||||
|
||||
### Validation
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `check.required()` | Required validation helper |
|
||||
| `check.email()` | Email validation helper |
|
||||
| `check.matches(path)` | Cross-field match helper |
|
||||
| `check.equalTo(path)` | Cross-field equality helper |
|
||||
| `check.lessThan(path)` | Cross-field less-than helper |
|
||||
| `check.greaterThan(path)` | Cross-field greater-than helper |
|
||||
| `check.requiredIf(path)` | Conditional required helper |
|
||||
| `builtInValidationFunctions` | All built-in validation functions |
|
||||
| `runValidationCheck()` | Run a single validation check |
|
||||
|
||||
### User Prompt
|
||||
|
||||
@@ -301,7 +285,6 @@ The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@j
|
||||
| `Spec` | Base spec type |
|
||||
| `Catalog` | Catalog type |
|
||||
| `BuiltInAction` | Built-in action type (`name` + `description`) |
|
||||
| `ComputedFunction` | Function signature for `$computed` expressions |
|
||||
| `VisibilityCondition` | Visibility condition type (used by `$cond`) |
|
||||
| `VisibilityContext` | Context for evaluating visibility and prop expressions |
|
||||
| `SpecStreamLine` | Single patch operation |
|
||||
@@ -389,44 +372,6 @@ Get the current array index inside a repeat:
|
||||
|
||||
`$index` uses `true` as a sentinel flag because the index is a scalar value with no sub-path to navigate (unlike `$item` which needs a path).
|
||||
|
||||
### Template (`$template`)
|
||||
|
||||
Interpolate state values into strings using `${/path}` syntax:
|
||||
|
||||
```json
|
||||
{
|
||||
"label": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
|
||||
}
|
||||
```
|
||||
|
||||
Missing paths resolve to an empty string.
|
||||
|
||||
### Computed (`$computed`)
|
||||
|
||||
Call a registered function with resolved arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": {
|
||||
"$computed": "fullName",
|
||||
"args": {
|
||||
"first": { "$state": "/form/firstName" },
|
||||
"last": { "$state": "/form/lastName" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Functions are registered in the catalog and provided at runtime via the `functions` prop on the renderer.
|
||||
|
||||
```typescript
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
|
||||
const functions: Record<string, ComputedFunction> = {
|
||||
fullName: (args) => `${args.first} ${args.last}`,
|
||||
};
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```typescript
|
||||
@@ -521,65 +466,6 @@ console.log(formatSpecIssues(issues));
|
||||
const fixed = autoFixSpec(spec);
|
||||
```
|
||||
|
||||
## State Watchers
|
||||
|
||||
Elements can declare a `watch` field to trigger actions when state values change. `watch` is a top-level field on the element (sibling of `type`, `props`, `children`), not inside `props`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "Country",
|
||||
"value": { "$bindState": "/form/country" },
|
||||
"options": ["US", "Canada", "UK"]
|
||||
},
|
||||
"watch": {
|
||||
"/form/country": {
|
||||
"action": "loadCities",
|
||||
"params": { "country": { "$state": "/form/country" } }
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Watchers only fire on value changes, not on initial render. Multiple action bindings per path execute sequentially.
|
||||
|
||||
## Validation
|
||||
|
||||
### Built-in Validation Functions
|
||||
|
||||
| Function | Description | Args |
|
||||
|----------|-------------|------|
|
||||
| `required` | Value must not be empty | — |
|
||||
| `email` | Must be a valid email | — |
|
||||
| `url` | Must be a valid URL | — |
|
||||
| `numeric` | Must be a number | — |
|
||||
| `minLength` | Minimum string length | `{ min: number }` |
|
||||
| `maxLength` | Maximum string length | `{ max: number }` |
|
||||
| `min` | Minimum numeric value | `{ min: number }` |
|
||||
| `max` | Maximum numeric value | `{ max: number }` |
|
||||
| `pattern` | Must match regex | `{ pattern: string }` |
|
||||
| `matches` | Must equal another field | `{ other: { $state: "/path" } }` |
|
||||
| `equalTo` | Alias for matches | `{ other: { $state: "/path" } }` |
|
||||
| `lessThan` | Must be less than another field | `{ other: { $state: "/path" } }` |
|
||||
| `greaterThan` | Must be greater than another field | `{ other: { $state: "/path" } }` |
|
||||
| `requiredIf` | Required when condition is truthy | `{ field: { $state: "/path" } }` |
|
||||
|
||||
### TypeScript Helpers
|
||||
|
||||
```typescript
|
||||
import { check } from "@json-render/core";
|
||||
|
||||
check.required("Field is required");
|
||||
check.email("Invalid email");
|
||||
check.matches("/form/password", "Passwords must match");
|
||||
check.equalTo("/form/password", "Passwords must match");
|
||||
check.lessThan("/form/endDate", "Must be before end date");
|
||||
check.greaterThan("/form/startDate", "Must be after start date");
|
||||
check.requiredIf("/form/enableNotifications", "Required when notifications enabled");
|
||||
```
|
||||
|
||||
## Custom Schemas
|
||||
|
||||
json-render supports completely different spec formats for different renderers:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# @json-render/jotai
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# @json-render/react-native
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-native",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# @json-render/react-pdf
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-pdf",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# @internal/react-state
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@internal/react-state",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "Shared React state context for json-render renderer packages",
|
||||
|
||||
@@ -1,81 +1,5 @@
|
||||
# @json-render/react
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -261,7 +261,6 @@ const { errors, validate } = useFieldValidation("/form/email", {
|
||||
| `useActions()` | Access action context |
|
||||
| `useAction(name)` | Get a single action dispatch function |
|
||||
| `useFieldValidation(path, config)` | Field validation state |
|
||||
| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) |
|
||||
| `useUIStream(options)` | Stream specs from an API endpoint |
|
||||
|
||||
## Visibility Conditions
|
||||
@@ -325,60 +324,11 @@ Any prop value can use data-driven expressions that resolve at render time. The
|
||||
|
||||
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
|
||||
|
||||
### `$template` and `$computed`
|
||||
|
||||
```json
|
||||
{
|
||||
"label": { "$template": "Hello, ${/user/name}!" },
|
||||
"fullName": {
|
||||
"$computed": "fullName",
|
||||
"args": {
|
||||
"first": { "$state": "/form/firstName" },
|
||||
"last": { "$state": "/form/lastName" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`:
|
||||
|
||||
```tsx
|
||||
<JSONUIProvider
|
||||
spec={spec}
|
||||
catalog={catalog}
|
||||
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
|
||||
>
|
||||
```
|
||||
|
||||
See [@json-render/core](../core/README.md) for full expression syntax.
|
||||
|
||||
## State Watchers
|
||||
|
||||
Elements can declare a `watch` field to trigger actions when state values change:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "Country",
|
||||
"value": { "$bindState": "/form/country" },
|
||||
"options": ["US", "Canada", "UK"]
|
||||
},
|
||||
"watch": {
|
||||
"/form/country": {
|
||||
"action": "loadCities",
|
||||
"params": { "country": { "$state": "/form/country" } }
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`), not inside `props`. Watchers only fire on value changes, not on initial render.
|
||||
|
||||
## Built-in Actions
|
||||
|
||||
The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`:
|
||||
The `setState`, `pushState`, and `removeState` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -394,26 +344,6 @@ The `setState`, `pushState`, `removeState`, and `validateForm` actions are built
|
||||
}
|
||||
```
|
||||
|
||||
### `validateForm`
|
||||
|
||||
Validate all registered form fields at once and write the result to state:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Button",
|
||||
"props": { "label": "Submit" },
|
||||
"on": {
|
||||
"press": [
|
||||
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||
{ "action": "submitForm" }
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Writes `{ valid: boolean, errors: Record<string, string[]> }` to the specified state path (defaults to `/formValidation`).
|
||||
|
||||
## Component Props
|
||||
|
||||
When using `defineRegistry`, components receive these props:
|
||||
@@ -521,7 +451,7 @@ function App() {
|
||||
|--------|---------|
|
||||
| `defineRegistry` | Create a type-safe component registry from a catalog |
|
||||
| `Renderer` | Render a spec using a registry |
|
||||
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) |
|
||||
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) |
|
||||
| `useStateStore` | Access state context |
|
||||
| `useStateValue` | Get single value from state |
|
||||
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React renderer for @json-render/core. JSON becomes React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -251,15 +251,9 @@ export function ActionProvider({
|
||||
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 });
|
||||
set(statePath, { valid });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -135,9 +134,6 @@ export function ValidationProvider({
|
||||
const [fieldStates, setFieldStates] = useState<
|
||||
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<
|
||||
Record<string, ValidationConfig>
|
||||
>({});
|
||||
@@ -179,16 +175,14 @@ export function ValidationProvider({
|
||||
customFunctions,
|
||||
});
|
||||
|
||||
const newFieldState: FieldValidationState = {
|
||||
touched: fieldStatesRef.current[path]?.touched ?? true,
|
||||
validated: true,
|
||||
result,
|
||||
};
|
||||
fieldStatesRef.current = {
|
||||
...fieldStatesRef.current,
|
||||
[path]: newFieldState,
|
||||
};
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
setFieldStates((prev) => ({
|
||||
...prev,
|
||||
[path]: {
|
||||
touched: prev[path]?.touched ?? true,
|
||||
validated: true,
|
||||
result,
|
||||
},
|
||||
}));
|
||||
|
||||
return result;
|
||||
},
|
||||
@@ -196,22 +190,22 @@ export function ValidationProvider({
|
||||
);
|
||||
|
||||
const touch = useCallback((path: string) => {
|
||||
fieldStatesRef.current = {
|
||||
...fieldStatesRef.current,
|
||||
setFieldStates((prev) => ({
|
||||
...prev,
|
||||
[path]: {
|
||||
...fieldStatesRef.current[path],
|
||||
...prev[path],
|
||||
touched: true,
|
||||
validated: fieldStatesRef.current[path]?.validated ?? false,
|
||||
result: fieldStatesRef.current[path]?.result ?? null,
|
||||
validated: prev[path]?.validated ?? false,
|
||||
result: prev[path]?.result ?? null,
|
||||
},
|
||||
};
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((path: string) => {
|
||||
const { [path]: _, ...rest } = fieldStatesRef.current;
|
||||
fieldStatesRef.current = rest;
|
||||
setFieldStates(rest);
|
||||
setFieldStates((prev) => {
|
||||
const { [path]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const validateAll = useCallback(() => {
|
||||
@@ -230,11 +224,7 @@ export function ValidationProvider({
|
||||
const value = useMemo<ValidationContextValue>(
|
||||
() => ({
|
||||
customFunctions,
|
||||
// Getter returns the mutable ref so callers that read fieldStates
|
||||
// synchronously after validateAll() see the latest values.
|
||||
get fieldStates() {
|
||||
return fieldStatesRef.current;
|
||||
},
|
||||
fieldStates,
|
||||
validate,
|
||||
touch,
|
||||
clear,
|
||||
@@ -243,8 +233,6 @@ export function ValidationProvider({
|
||||
}),
|
||||
[
|
||||
customFunctions,
|
||||
// fieldStates (React state) stays in deps so the context value object
|
||||
// is recreated on re-render, triggering downstream consumers.
|
||||
fieldStates,
|
||||
validate,
|
||||
touch,
|
||||
|
||||
@@ -546,10 +546,7 @@ describe("validateForm action", () => {
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/email": ["Email is required"] },
|
||||
});
|
||||
expect(state.result).toEqual({ valid: false });
|
||||
});
|
||||
|
||||
it("writes { valid: true } when all fields pass validation", async () => {
|
||||
@@ -604,7 +601,7 @@ describe("validateForm action", () => {
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.result).toEqual({ valid: true, errors: {} });
|
||||
expect(state.result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it("defaults to /formValidation when no statePath is provided", async () => {
|
||||
@@ -654,7 +651,7 @@ describe("validateForm action", () => {
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.formValidation).toEqual({ valid: true, errors: {} });
|
||||
expect(state.formValidation).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export const schema = defineSchema(
|
||||
{
|
||||
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[]> }.",
|
||||
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
|
||||
},
|
||||
],
|
||||
defaultRules: [
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# @json-render/redux
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/redux",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Redux adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# @json-render/remotion
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/remotion",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,82 +1,5 @@
|
||||
# @json-render/shadcn
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -167,12 +167,12 @@ const { registry } = defineRegistry(catalog, {
|
||||
|-----------|-------------|
|
||||
| `Button` | Clickable button with variants |
|
||||
| `Link` | Anchor link |
|
||||
| `Input` | Text input with label, validation, and `validateOn` timing |
|
||||
| `Textarea` | Multi-line text input with validation and `validateOn` |
|
||||
| `Select` | Dropdown select with validation and `validateOn` |
|
||||
| `Checkbox` | Checkbox input with validation and `validateOn` |
|
||||
| `Radio` | Radio button group with validation and `validateOn` |
|
||||
| `Switch` | Toggle switch with validation and `validateOn` |
|
||||
| `Input` | Text input with label and validation |
|
||||
| `Textarea` | Multi-line text input |
|
||||
| `Select` | Dropdown select |
|
||||
| `Checkbox` | Checkbox input |
|
||||
| `Radio` | Radio button group |
|
||||
| `Switch` | Toggle switch |
|
||||
| `Slider` | Range slider |
|
||||
| `Toggle` | Toggle button |
|
||||
| `ToggleGroup` | Group of toggle buttons |
|
||||
@@ -180,24 +180,13 @@ const { registry } = defineRegistry(catalog, {
|
||||
|
||||
## Built-in Actions
|
||||
|
||||
State actions (`setState`, `pushState`, `removeState`, `validateForm`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog.
|
||||
State actions (`setState`, `pushState`, `removeState`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog.
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `setState` | Set a value at a state path |
|
||||
| `pushState` | Push a value onto an array in state |
|
||||
| `removeState` | Remove an item from an array in state |
|
||||
| `validateForm` | Validate all fields and write result to state |
|
||||
|
||||
### Validation Timing (`validateOn`)
|
||||
|
||||
All form components support the `validateOn` prop to control when validation runs:
|
||||
|
||||
| Value | Description | Default For |
|
||||
|-------|-------------|-------------|
|
||||
| `"change"` | Validate on every input change | Select, Checkbox, Radio, Switch |
|
||||
| `"blur"` | Validate when field loses focus | Input, Textarea |
|
||||
| `"submit"` | Validate only on form submission | — |
|
||||
|
||||
## Exports
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/shadcn",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue"
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
# @json-render/vue
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
+145
-366
@@ -8,8 +8,6 @@ Vue 3 renderer for json-render. Turn JSON specs into Vue components with data bi
|
||||
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
|
||||
@@ -31,7 +29,6 @@ export const catalog = defineCatalog(schema, {
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
action: z.string(),
|
||||
}),
|
||||
description: "A clickable button",
|
||||
},
|
||||
@@ -46,20 +43,15 @@ export const catalog = defineCatalog(schema, {
|
||||
},
|
||||
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 { defineRegistry, useBoundProp } from "@json-render/vue";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
@@ -67,430 +59,217 @@ export const { registry } = defineRegistry(catalog, {
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "card" }, [
|
||||
h("h3", null, props.title),
|
||||
props.description ? h("p", null, props.description) : null,
|
||||
props.description && h("p", null, props.description),
|
||||
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
|
||||
const [value, setValue] = useBoundProp(props.value, bindings?.value);
|
||||
return h("label", null, [
|
||||
props.label,
|
||||
h("input", {
|
||||
placeholder: props.placeholder ?? "",
|
||||
value: props.value ?? "",
|
||||
value: value ?? "",
|
||||
onInput: (e: Event) =>
|
||||
setValue((e.target as HTMLInputElement).value),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
},
|
||||
// actions stubs are required when the catalog declares actions:
|
||||
actions: {
|
||||
submit: async () => {},
|
||||
cancel: async () => {},
|
||||
submit: async (_params, _setState) => {
|
||||
console.log("Form submitted");
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
> **Tip:** Use `useBoundProp(props.value, bindings?.value)` for two-way binding, or handle the `bindings` object directly in your component.
|
||||
|
||||
### 3. Render Specs
|
||||
### 3. Render a Spec
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { StateProvider, ActionProvider, Renderer } from "@json-render/vue";
|
||||
import { JSONUIProvider, Renderer } from "@json-render/vue";
|
||||
import { registry } from "./registry";
|
||||
|
||||
const spec = { /* ... */ };
|
||||
|
||||
function handleSubmit(params) {
|
||||
console.log("Submit", params);
|
||||
}
|
||||
const spec = {
|
||||
root: "card",
|
||||
state: { name: "" },
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Hello", description: null },
|
||||
children: ["input", "btn"],
|
||||
},
|
||||
input: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
placeholder: "Enter your name",
|
||||
value: { $bindState: "/name" },
|
||||
},
|
||||
},
|
||||
btn: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: { press: { action: "submit" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateProvider :initial-state="{ form: { name: '' } }">
|
||||
<ActionProvider :handlers="{ submit: handleSubmit }">
|
||||
<Renderer :spec="spec" :registry="registry" />
|
||||
</ActionProvider>
|
||||
</StateProvider>
|
||||
<JSONUIProvider :registry="registry" :initial-state="spec.state ?? {}">
|
||||
<Renderer :spec="spec" :registry="registry" />
|
||||
</JSONUIProvider>
|
||||
</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:
|
||||
The spec is a flat map of elements. Each element references its children by key:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "card-1",
|
||||
"root": "card",
|
||||
"state": { "count": 0 },
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"card": {
|
||||
"type": "Card",
|
||||
"props": { "title": "Welcome" },
|
||||
"children": ["input-1", "btn-1"]
|
||||
"props": { "title": "Counter" },
|
||||
"children": ["display", "increment"]
|
||||
},
|
||||
"input-1": {
|
||||
"type": "Input",
|
||||
"props": {
|
||||
"value": { "$bindState": "/form/name" },
|
||||
"label": "Name",
|
||||
"placeholder": "Enter name"
|
||||
}
|
||||
"display": {
|
||||
"type": "Text",
|
||||
"props": { "text": { "$state": "/count" } }
|
||||
},
|
||||
"btn-1": {
|
||||
"increment": {
|
||||
"type": "Button",
|
||||
"props": { "label": "Submit" },
|
||||
"children": []
|
||||
"props": { "label": "+1" },
|
||||
"on": {
|
||||
"press": {
|
||||
"action": "setState",
|
||||
"params": { "statePath": "/count", "value": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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 |
|
||||
### `useStateStore()`
|
||||
|
||||
> **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
|
||||
Access the state context inside any component rendered within a `JSONUIProvider`.
|
||||
|
||||
```typescript
|
||||
// Truthiness check
|
||||
{ "$state": "/user/isAdmin" }
|
||||
import { useStateStore } from "@json-render/vue";
|
||||
|
||||
// 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
|
||||
const { state, get, set, getSnapshot } = useStateStore();
|
||||
```
|
||||
|
||||
TypeScript helpers from `@json-render/core`:
|
||||
### `useBoundProp(propValue, bindingPath)`
|
||||
|
||||
Two-way binding helper for `$bindState` / `$bindItem` expressions.
|
||||
|
||||
```typescript
|
||||
import { visibility } from "@json-render/core";
|
||||
import { useBoundProp } from "@json-render/vue";
|
||||
|
||||
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
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
```
|
||||
|
||||
### `useVisibility()`
|
||||
|
||||
Evaluate visibility conditions.
|
||||
|
||||
```typescript
|
||||
import { useVisibility } from "@json-render/vue";
|
||||
|
||||
const { isVisible, ctx } = useVisibility();
|
||||
```
|
||||
|
||||
### `useActions()`
|
||||
|
||||
Access action execution.
|
||||
|
||||
```typescript
|
||||
import { useActions } from "@json-render/vue";
|
||||
|
||||
const { execute, loadingActions } = useActions();
|
||||
```
|
||||
|
||||
### `useFieldValidation(path, config)`
|
||||
|
||||
Field-level validation.
|
||||
|
||||
```typescript
|
||||
import { useFieldValidation } from "@json-render/vue";
|
||||
|
||||
const { errors, isValid, validate, touch } = useFieldValidation("/email", {
|
||||
checks: [{ type: "required", message: "Email is required" }],
|
||||
});
|
||||
```
|
||||
|
||||
## Provider Components
|
||||
|
||||
All providers are renderless components that use Vue's `provide`/`inject`:
|
||||
|
||||
- **`StateProvider`** -- Manages state model with `createStateStore` from core
|
||||
- **`VisibilityProvider`** -- Evaluates visibility conditions against state
|
||||
- **`ValidationProvider`** -- Handles form validation
|
||||
- **`ActionProvider`** -- Executes action bindings (setState, pushState, custom actions)
|
||||
- **`JSONUIProvider`** -- Composes all of the above into a single provider
|
||||
|
||||
## 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.
|
||||
Props support dynamic expressions that resolve against state:
|
||||
|
||||
```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.
|
||||
| Expression | Description |
|
||||
|---|---|
|
||||
| `{ "$state": "/path" }` | Read a value from state |
|
||||
| `{ "$bindState": "/path" }` | Two-way binding to state |
|
||||
| `{ "$item": "field" }` | Read from current repeat item |
|
||||
| `{ "$bindItem": "field" }` | Two-way bind to repeat item field |
|
||||
| `{ "$index": true }` | Current repeat index |
|
||||
| `{ "$cond": ..., "$then": ..., "$else": ... }` | Conditional value |
|
||||
| `{ "$computed": "fnName", "args": {...} }` | Computed function |
|
||||
| `{ "$template": "Hello ${/name}" }` | String interpolation |
|
||||
|
||||
## 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>
|
||||
```
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `setState` | Set a value at a state path |
|
||||
| `pushState` | Append to an array in state |
|
||||
| `removeState` | Remove an item from an array by index |
|
||||
| `validateForm` | Validate all registered fields |
|
||||
|
||||
## 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` |
|
||||
```typescript
|
||||
// Renderer
|
||||
export { Renderer, JSONUIProvider, defineRegistry, createRenderer, useBoundProp };
|
||||
|
||||
### Types
|
||||
// Composables
|
||||
export { useStateStore, useStateValue };
|
||||
export { useVisibility, useIsVisible };
|
||||
export { useActions, useAction };
|
||||
export { useValidation, useFieldValidation };
|
||||
export { useRepeatScope };
|
||||
|
||||
| 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 |
|
||||
// Provider components
|
||||
export { StateProvider, VisibilityProvider, ActionProvider, ValidationProvider };
|
||||
export { RepeatScopeProvider };
|
||||
|
||||
## Differences from `@json-render/react`
|
||||
// Schema
|
||||
export { schema };
|
||||
|
||||
| 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 |
|
||||
// Types
|
||||
export type {
|
||||
ComponentRenderProps, ComponentRenderer, ComponentRegistry,
|
||||
RendererProps, JSONUIProviderProps, CreateRendererProps,
|
||||
BaseComponentProps, ComponentContext, ComponentFn,
|
||||
EventHandle, SetState, StateModel,
|
||||
};
|
||||
```
|
||||
|
||||
+13
-37
@@ -1,44 +1,22 @@
|
||||
{
|
||||
"name": "@json-render/vue",
|
||||
"version": "0.10.0",
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema.d.ts",
|
||||
"import": "./dist/schema.mjs",
|
||||
"require": "./dist/schema.js"
|
||||
"import": "./dist/schema.js",
|
||||
"require": "./dist/schema.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -47,20 +25,18 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"check-types": "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"
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vue": "^3.5.29"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"zod": "^4.0.0"
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,114 +10,39 @@ import type {
|
||||
|
||||
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. */
|
||||
children?: VNode[];
|
||||
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;
|
||||
> = (ctx: ComponentContext<C, K>) => VNode | VNode[] | null;
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
@@ -127,22 +52,10 @@ export type ActionFn<
|
||||
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]
|
||||
|
||||
@@ -1,106 +1,216 @@
|
||||
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";
|
||||
import { defineComponent, h, createApp, nextTick } from "vue";
|
||||
import { ActionProvider, useActions } from "./actions.js";
|
||||
import { StateProvider, useStateStore } from "./state.js";
|
||||
import { VisibilityProvider } from "./visibility.js";
|
||||
import { ValidationProvider } from "./validation.js";
|
||||
|
||||
/** 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({
|
||||
function runInProviders<T>(
|
||||
initialState: Record<string, unknown>,
|
||||
fn: () => T,
|
||||
handlers?: Record<string, (...args: unknown[]) => void>,
|
||||
): T {
|
||||
let result: T | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
result = composable();
|
||||
return () => h("div");
|
||||
result = fn();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
mount(StateProvider as Component, {
|
||||
props: { initialState } as any,
|
||||
slots: {
|
||||
default: () =>
|
||||
h(ActionProvider as Component, { handlers } as any, {
|
||||
default: () => h(Child),
|
||||
}),
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: handlers ?? {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
return { result };
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
app.unmount();
|
||||
|
||||
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("useActions", () => {
|
||||
it("provides execute function", () => {
|
||||
const ctx = runInProviders({}, () => useActions());
|
||||
expect(typeof ctx.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("provides confirm and cancel functions", () => {
|
||||
const ctx = runInProviders({}, () => useActions());
|
||||
expect(typeof ctx.confirm).toBe("function");
|
||||
expect(typeof ctx.cancel).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
describe("action execution", () => {
|
||||
it("executes setState action", async () => {
|
||||
let storeCtx: ReturnType<typeof useStateStore> | undefined;
|
||||
let actionsCtx: ReturnType<typeof useActions> | undefined;
|
||||
|
||||
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({
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
stateCtx = useStateStore();
|
||||
storeCtx = useStateStore();
|
||||
actionsCtx = useActions();
|
||||
return () => h("div");
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
mount(StateProvider as Component, {
|
||||
props: { initialState: { v: 0 } } as any,
|
||||
slots: {
|
||||
default: () =>
|
||||
h(ActionProvider as Component, null, {
|
||||
default: () => h(Child),
|
||||
}),
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState: { count: 0 } }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await actionsCtx.execute({
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
await actionsCtx!.execute({
|
||||
action: "setState",
|
||||
params: { statePath: "/v", value: 42 },
|
||||
params: { statePath: "/count", 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);
|
||||
expect(storeCtx!.get("/count")).toBe(42);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("executes pushState action", async () => {
|
||||
let storeCtx: ReturnType<typeof useStateStore> | undefined;
|
||||
let actionsCtx: ReturnType<typeof useActions> | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
storeCtx = useStateStore();
|
||||
actionsCtx = useActions();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState: { items: ["a"] } }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
await actionsCtx!.execute({
|
||||
action: "pushState",
|
||||
params: { statePath: "/items", value: "b" },
|
||||
});
|
||||
|
||||
expect(storeCtx!.get("/items")).toEqual(["a", "b"]);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("executes removeState action", async () => {
|
||||
let storeCtx: ReturnType<typeof useStateStore> | undefined;
|
||||
let actionsCtx: ReturnType<typeof useActions> | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
storeCtx = useStateStore();
|
||||
actionsCtx = useActions();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState: { items: ["a", "b", "c"] } }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
await actionsCtx!.execute({
|
||||
action: "removeState",
|
||||
params: { statePath: "/items", index: 1 },
|
||||
});
|
||||
|
||||
expect(storeCtx!.get("/items")).toEqual(["a", "c"]);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("warns when no handler is registered for custom action", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const actionsCtx = runInProviders({}, () => useActions());
|
||||
|
||||
// The provider is unmounted, but execute still runs synchronously for the warn path
|
||||
// We need a mounted tree for this test
|
||||
let actions: ReturnType<typeof useActions> | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
actions = useActions();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState: {} }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
await actions!.execute({ action: "unknownAction" });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"No handler registered for action: unknownAction",
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user