mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc38be8efe | ||
|
|
aa7eef1aea | ||
|
|
ea47b66dfc | ||
|
|
cd82f969c8 | ||
|
|
6bcaaad57d | ||
|
|
0b7d767cdd | ||
|
|
b1036763d2 | ||
|
|
c502d5517e | ||
|
|
8740deb018 | ||
|
|
1d755c104a | ||
|
|
d904d45150 | ||
|
|
a110c6e0ea | ||
|
|
7de08ccdf7 | ||
|
|
3201854481 | ||
|
|
64c889221e | ||
|
|
49838fa353 | ||
|
|
fa47b08869 | ||
|
|
5ccb109c08 | ||
|
|
62932f6516 | ||
|
|
ee28d548c1 | ||
|
|
0e6f2afc6c | ||
|
|
bccedc2459 | ||
|
|
0a404302ef | ||
|
|
09376db2f6 | ||
|
|
4c294417f4 | ||
|
|
f77c1d6c98 | ||
|
|
a66aef17f9 | ||
|
|
ba9ffa3c91 | ||
|
|
b7d5a75bfa | ||
|
|
0dfe07da45 | ||
|
|
c82eefd1c5 | ||
|
|
2d70fab00a | ||
|
|
320c935bfb |
@@ -6,9 +6,14 @@
|
||||
[
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/react-pdf",
|
||||
"@json-render/shadcn",
|
||||
"@json-render/react-native",
|
||||
"@json-render/remotion",
|
||||
"@json-render/codegen"
|
||||
"@json-render/codegen",
|
||||
"@json-render/zustand",
|
||||
"@json-render/redux",
|
||||
"@json-render/jotai"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
@@ -16,7 +21,7 @@
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"version": true,
|
||||
"tag": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.0.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
+9
-6
@@ -6,11 +6,8 @@ node_modules
|
||||
.pnp.js
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
@@ -26,6 +23,8 @@ coverage
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
.nuxt/
|
||||
.output/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
@@ -43,4 +42,8 @@ yarn-error.log*
|
||||
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
.env*.local
|
||||
|
||||
# Stripe apps (generated from template + build artifacts)
|
||||
examples/stripe-app/*/stripe-app.json
|
||||
examples/stripe-app/*/.build
|
||||
examples/stripe-app/*/yarn.lock
|
||||
|
||||
@@ -25,12 +25,52 @@ This ensures we don't install outdated versions that may have incompatible types
|
||||
## Code Style
|
||||
|
||||
- Do not use emojis in code or UI
|
||||
- Do not use barrel files (index.ts that re-exports from other files)
|
||||
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
|
||||
|
||||
## AI SDK / AI Gateway
|
||||
|
||||
When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor:
|
||||
|
||||
```ts
|
||||
import { streamText } from "ai";
|
||||
|
||||
const result = streamText({
|
||||
model: "anthropic/claude-haiku-4.5",
|
||||
prompt: "...",
|
||||
});
|
||||
```
|
||||
|
||||
This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
|
||||
|
||||
Naming convention:
|
||||
- Main web app: `json-render` → `json-render.localhost:1355`
|
||||
- Examples: `[name]-demo.json-render` → `[name]-demo.json-render.localhost:1355`
|
||||
|
||||
When adding a new example that runs a dev server, wrap its `dev` script with `portless <name>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "portless my-example-demo.json-render next dev --turbopack"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do **not** add `--port` flags -- portless handles port assignment automatically. Do **not** add portless as a project dependency; it must be installed globally.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Run `pnpm type-check` after each turn to ensure type safety
|
||||
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
|
||||
- Package `README.md` files in `packages/*/README.md`
|
||||
- Root `README.md` (if packages table, install commands, or examples are affected)
|
||||
- Web app docs in `apps/web/` (if guides, API references, or examples need updating)
|
||||
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
|
||||
- `AGENTS.md` (if workflow or conventions change)
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
|
||||
@@ -6,10 +6,14 @@ Generate dynamic, personalized UIs from prompts without sacrificing reliability.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react
|
||||
# pre-built shadcn/ui components
|
||||
npm install @json-render/shadcn
|
||||
# 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
|
||||
```
|
||||
|
||||
## Why json-render?
|
||||
@@ -20,6 +24,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
|
||||
- **Predictable** - JSON output matches your schema, every time
|
||||
- **Fast** - Stream and render progressively as the model responds
|
||||
- **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
|
||||
|
||||
@@ -27,7 +32,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
@@ -105,8 +110,14 @@ function Dashboard({ spec }) {
|
||||
|---------|-------------|
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||
| `@json-render/react` | React renderer, contexts, hooks |
|
||||
| `@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 |
|
||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||
| `@json-render/xstate-store` | XState Store (atom) adapter for `StateStore` |
|
||||
|
||||
## Renderers
|
||||
|
||||
@@ -114,7 +125,7 @@ function Dashboard({ spec }) {
|
||||
|
||||
```tsx
|
||||
import { defineRegistry, Renderer } from "@json-render/react";
|
||||
import { schema } from "@json-render/react";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
|
||||
// Flat spec format (root key + elements map)
|
||||
const spec = {
|
||||
@@ -138,6 +149,39 @@ const { registry } = defineRegistry(catalog, { components });
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### shadcn/ui (Web)
|
||||
|
||||
```tsx
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { defineRegistry, Renderer } from "@json-render/react";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
|
||||
// Pick components from the 36 standard definitions
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
// Use matching implementations
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Button: shadcnComponents.Button,
|
||||
},
|
||||
});
|
||||
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### React Native (Mobile)
|
||||
|
||||
```tsx
|
||||
@@ -185,6 +229,40 @@ const spec = {
|
||||
/>
|
||||
```
|
||||
|
||||
### React PDF (Documents)
|
||||
|
||||
```typescript
|
||||
import { renderToBuffer } from "@json-render/react-pdf";
|
||||
|
||||
const spec = {
|
||||
root: "doc",
|
||||
elements: {
|
||||
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
|
||||
"page-1": {
|
||||
type: "Page",
|
||||
props: { size: "A4" },
|
||||
children: ["heading-1", "table-1"],
|
||||
},
|
||||
"heading-1": {
|
||||
type: "Heading",
|
||||
props: { text: "Invoice #1234", level: "h1" },
|
||||
children: [],
|
||||
},
|
||||
"table-1": {
|
||||
type: "Table",
|
||||
props: {
|
||||
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
|
||||
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Render to buffer, stream, or file
|
||||
const buffer = await renderToBuffer(spec);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Streaming (SpecStream)
|
||||
@@ -270,9 +348,9 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- http://localhost:3000 - Docs & Playground
|
||||
- http://localhost:3001 - Example Dashboard
|
||||
- http://localhost:3002 - Remotion Video Example
|
||||
- http://json-render.localhost:1355 - Docs & Playground
|
||||
- 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`
|
||||
- React Native example: run `npx expo start` in `examples/react-native`
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# web
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/codegen@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1d755c1]
|
||||
- @json-render/core@0.9.0
|
||||
- @json-render/react@0.9.0
|
||||
- @json-render/codegen@0.9.0
|
||||
+1
-1
@@ -14,7 +14,7 @@ pnpm dev
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Open [http://json-render.localhost:1355](http://json-render.localhost:1355) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "A2UI Integration" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/a2ui")
|
||||
|
||||
# A2UI Integration
|
||||
|
||||
@@ -66,7 +67,7 @@ A2UI uses an adjacency list model - a flat list of components with ID references
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
// A2UI BoundValue schema
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Adaptive Cards Integration" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/adaptive-cards")
|
||||
|
||||
# Adaptive Cards Integration
|
||||
|
||||
@@ -89,7 +90,7 @@ Define a catalog matching the Adaptive Cards element types:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Common Adaptive Cards properties
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "AG-UI Integration" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/ag-ui")
|
||||
|
||||
# AG-UI Integration
|
||||
|
||||
@@ -150,7 +151,7 @@ Create a catalog for UI components that agents can render:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aguiCatalog = defineCatalog(schema, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "AI SDK Integration" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/ai-sdk")
|
||||
|
||||
# AI SDK Integration
|
||||
|
||||
@@ -191,13 +192,44 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
|
||||
|
||||
## Which Mode?
|
||||
|
||||
| | Generate | Chat |
|
||||
|---|---|---|
|
||||
| Output | JSONL only | Text + JSONL |
|
||||
| Text-only replies | No | Yes |
|
||||
| System prompt | `catalog.prompt()` | `catalog.prompt({ mode: "chat" })` |
|
||||
| Stream utility | `useUIStream` | `pipeJsonRender` + `useJsonRenderMessage` |
|
||||
| Use case | Playgrounds, builders | Chatbots, copilots |
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Generate</th>
|
||||
<th>Chat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Output</td>
|
||||
<td>JSONL only</td>
|
||||
<td>Text + JSONL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Text-only replies</td>
|
||||
<td>No</td>
|
||||
<td>Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>System prompt</td>
|
||||
<td><code>catalog.prompt()</code></td>
|
||||
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stream utility</td>
|
||||
<td><code>useUIStream</code></td>
|
||||
<td><code>pipeJsonRender</code> + <code>useJsonRenderMessage</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Use case</td>
|
||||
<td>Playgrounds, builders</td>
|
||||
<td>Chatbots, copilots</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Learn more in the [Generation Modes](/docs/generation-modes) guide.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "@json-render/codegen API" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/codegen")
|
||||
|
||||
# @json-render/codegen
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "@json-render/core API" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/core")
|
||||
|
||||
# @json-render/core
|
||||
|
||||
@@ -10,7 +11,7 @@ Creates a type-safe catalog definition with schema validation.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
|
||||
function defineCatalog<T extends ZodType>(
|
||||
s: T,
|
||||
@@ -118,7 +119,7 @@ The schema for flat UI element trees. This is exported from @json-render/react.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
|
||||
// schema defines:
|
||||
// - Spec shape: { root: string, elements: Record<string, UIElement> }
|
||||
@@ -141,6 +142,25 @@ const catalog = defineCatalog(schema, {
|
||||
});
|
||||
```
|
||||
|
||||
### SchemaOptions
|
||||
|
||||
When creating schemas with `defineSchema`, you can pass options:
|
||||
|
||||
```typescript
|
||||
interface SchemaOptions {
|
||||
promptTemplate?: PromptTemplate; // Custom AI prompt generator
|
||||
defaultRules?: string[]; // Default rules injected before custom rules in prompts
|
||||
builtInActions?: BuiltInAction[]; // Actions always available at runtime, auto-injected into prompts
|
||||
}
|
||||
|
||||
interface BuiltInAction {
|
||||
name: string; // Action name (e.g. "setState")
|
||||
description: string; // Human-readable description for the LLM
|
||||
}
|
||||
```
|
||||
|
||||
Built-in actions are injected into prompts as `[built-in]` and are handled by the runtime (e.g. `ActionProvider`) without requiring handlers in `defineRegistry`. The React schema declares `setState`, `pushState`, and `removeState` as built-in.
|
||||
|
||||
### defineSchema
|
||||
|
||||
Create custom schemas for different output formats (e.g., page-based, block-based).
|
||||
@@ -333,6 +353,21 @@ const flat = nestedToFlat({
|
||||
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
|
||||
```
|
||||
|
||||
### createJsonRenderTransform
|
||||
|
||||
Low-level `TransformStream` that separates text from JSONL patches in a mixed AI stream. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text.
|
||||
|
||||
The transform properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
|
||||
|
||||
```typescript
|
||||
import { createJsonRenderTransform } from '@json-render/core';
|
||||
|
||||
const transform = createJsonRenderTransform();
|
||||
// Use with ReadableStream.pipeThrough(transform) for custom pipelines
|
||||
```
|
||||
|
||||
Most users should use `pipeJsonRender()` instead, which wraps this transform for the common AI SDK use case.
|
||||
|
||||
### createMixedStreamParser
|
||||
|
||||
Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
|
||||
@@ -556,6 +591,7 @@ interface ActionBinding {
|
||||
};
|
||||
onSuccess?: { set: Record<string, unknown> };
|
||||
onError?: { set: Record<string, unknown> };
|
||||
preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "@json-render/react-native API" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/react-native")
|
||||
|
||||
# @json-render/react-native
|
||||
|
||||
@@ -62,11 +63,36 @@ React Native renderer with standard components, providers, and hooks.
|
||||
### StateProvider
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={object}>
|
||||
<StateProvider initialState={object} onStateChange={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:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react-native";
|
||||
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — components re-render automatically:
|
||||
store.set("/count", 1);
|
||||
```
|
||||
|
||||
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||
|
||||
### ActionProvider
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/react-pdf")
|
||||
|
||||
# @json-render/react-pdf
|
||||
|
||||
PDF document renderer. Turn JSON specs into PDFs using `@react-pdf/renderer`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react-pdf
|
||||
```
|
||||
|
||||
See the [React PDF example](https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf) for a full working example.
|
||||
|
||||
## schema
|
||||
|
||||
The PDF element schema for document specs. Use with `defineCatalog` from core.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema, standardComponentDefinitions } from '@json-render/react-pdf';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
});
|
||||
```
|
||||
|
||||
## Render Functions
|
||||
|
||||
Server-side functions for producing PDF output. All accept a spec and optional `RenderOptions`.
|
||||
|
||||
```typescript
|
||||
import { renderToBuffer, renderToStream, renderToFile } from '@json-render/react-pdf';
|
||||
|
||||
const buffer = await renderToBuffer(spec);
|
||||
|
||||
const stream = await renderToStream(spec);
|
||||
stream.pipe(res);
|
||||
|
||||
await renderToFile(spec, './output.pdf');
|
||||
```
|
||||
|
||||
### RenderOptions
|
||||
|
||||
```typescript
|
||||
interface RenderOptions {
|
||||
registry?: ComponentRegistry;
|
||||
includeStandard?: boolean; // default: true
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>registry</code></td>
|
||||
<td>Custom component map (merged with standard components)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>includeStandard</code></td>
|
||||
<td>Include built-in standard components (default: <code>true</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>state</code></td>
|
||||
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from '@json-render/react-pdf';
|
||||
import { View, Text } from '@react-pdf/renderer';
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Badge: ({ props }) => (
|
||||
<View style={{ backgroundColor: props.color ?? '#e5e7eb', padding: 4, borderRadius: 4 }}>
|
||||
<Text style={{ fontSize: 10 }}>{props.label}</Text>
|
||||
</View>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const buffer = await renderToBuffer(spec, { registry });
|
||||
```
|
||||
|
||||
## createRenderer
|
||||
|
||||
Create a standalone renderer component wired to state, actions, and validation.
|
||||
|
||||
```typescript
|
||||
import { createRenderer } from '@json-render/react-pdf';
|
||||
|
||||
const PDFRenderer = createRenderer(catalog, components);
|
||||
```
|
||||
|
||||
```typescript
|
||||
interface CreateRendererProps {
|
||||
spec: Spec | null;
|
||||
store?: StateStore;
|
||||
state?: Record<string, unknown>;
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
```
|
||||
|
||||
When `store` is provided, `state` and `onStateChange` are ignored (controlled mode).
|
||||
|
||||
## Renderer
|
||||
|
||||
The main component that renders a spec to `@react-pdf/renderer` elements.
|
||||
|
||||
```typescript
|
||||
interface RendererProps {
|
||||
spec: Spec | null;
|
||||
registry?: ComponentRegistry;
|
||||
includeStandard?: boolean; // default: true
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
```
|
||||
|
||||
## Standard Components
|
||||
|
||||
### Document Structure
|
||||
|
||||
#### Document
|
||||
|
||||
Top-level PDF wrapper. Must be the root element. Children must be `Page` components.
|
||||
|
||||
```typescript
|
||||
{
|
||||
title: string | null;
|
||||
author: string | null;
|
||||
subject: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Page
|
||||
|
||||
A page in the document with configurable size, orientation, and margins.
|
||||
|
||||
```typescript
|
||||
{
|
||||
size: "A4" | "A3" | "A5" | "LETTER" | "LEGAL" | "TABLOID" | null;
|
||||
orientation: "portrait" | "landscape" | null;
|
||||
marginTop: number | null;
|
||||
marginBottom: number | null;
|
||||
marginLeft: number | null;
|
||||
marginRight: number | null;
|
||||
backgroundColor: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Layout
|
||||
|
||||
#### View
|
||||
|
||||
Generic container with padding, margin, background, border, and flex alignment.
|
||||
|
||||
```typescript
|
||||
{
|
||||
padding: number | null;
|
||||
paddingTop: number | null;
|
||||
paddingBottom: number | null;
|
||||
paddingLeft: number | null;
|
||||
paddingRight: number | null;
|
||||
margin: number | null;
|
||||
backgroundColor: string | null;
|
||||
borderWidth: number | null;
|
||||
borderColor: string | null;
|
||||
borderRadius: number | null;
|
||||
flex: number | null;
|
||||
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Row
|
||||
|
||||
Horizontal flex layout with optional wrapping.
|
||||
|
||||
```typescript
|
||||
{
|
||||
gap: number | null;
|
||||
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||
padding: number | null;
|
||||
flex: number | null;
|
||||
wrap: boolean | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Column
|
||||
|
||||
Vertical flex layout.
|
||||
|
||||
```typescript
|
||||
{
|
||||
gap: number | null;
|
||||
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||
padding: number | null;
|
||||
flex: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
#### Heading
|
||||
|
||||
h1-h4 heading text with configurable color and alignment.
|
||||
|
||||
```typescript
|
||||
{
|
||||
text: string;
|
||||
level: "h1" | "h2" | "h3" | "h4" | null;
|
||||
color: string | null;
|
||||
align: "left" | "center" | "right" | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Text
|
||||
|
||||
Body text with full styling control.
|
||||
|
||||
```typescript
|
||||
{
|
||||
text: string;
|
||||
fontSize: number | null;
|
||||
color: string | null;
|
||||
align: "left" | "center" | "right" | null;
|
||||
fontWeight: "normal" | "bold" | null;
|
||||
fontStyle: "normal" | "italic" | null;
|
||||
lineHeight: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Image
|
||||
|
||||
Image from a URL with optional dimensions and fit.
|
||||
|
||||
```typescript
|
||||
{
|
||||
src: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
objectFit: "contain" | "cover" | "fill" | "none" | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Link
|
||||
|
||||
Hyperlink with visible text.
|
||||
|
||||
```typescript
|
||||
{
|
||||
text: string;
|
||||
href: string;
|
||||
fontSize: number | null;
|
||||
color: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Data
|
||||
|
||||
#### Table
|
||||
|
||||
Data table with typed columns and string rows. Supports header styling and striped rows.
|
||||
|
||||
```typescript
|
||||
{
|
||||
columns: { header: string; width?: string; align?: "left" | "center" | "right" }[];
|
||||
rows: string[][];
|
||||
headerBackgroundColor: string | null;
|
||||
headerTextColor: string | null;
|
||||
borderColor: string | null;
|
||||
fontSize: number | null;
|
||||
striped: boolean | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### List
|
||||
|
||||
Ordered or unordered list.
|
||||
|
||||
```typescript
|
||||
{
|
||||
items: string[];
|
||||
ordered: boolean | null;
|
||||
fontSize: number | null;
|
||||
color: string | null;
|
||||
spacing: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Decorative
|
||||
|
||||
#### Divider
|
||||
|
||||
Horizontal line separator.
|
||||
|
||||
```typescript
|
||||
{
|
||||
color: string | null;
|
||||
thickness: number | null;
|
||||
marginTop: number | null;
|
||||
marginBottom: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Spacer
|
||||
|
||||
Empty vertical space.
|
||||
|
||||
```typescript
|
||||
{
|
||||
height: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Page-Level
|
||||
|
||||
#### PageNumber
|
||||
|
||||
Renders current page number and total pages. Format uses `{pageNumber}` and `{totalPages}` placeholders.
|
||||
|
||||
```typescript
|
||||
{
|
||||
format: string | null; // default: "{pageNumber} / {totalPages}"
|
||||
fontSize: number | null;
|
||||
color: string | null;
|
||||
align: "left" | "center" | "right" | null;
|
||||
}
|
||||
```
|
||||
|
||||
## External Store (Controlled Mode)
|
||||
|
||||
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react-pdf";
|
||||
|
||||
const store = createStateStore({ invoice: { total: 100 } });
|
||||
store.set("/invoice/total", 200);
|
||||
```
|
||||
|
||||
When `store` is provided, `initialState` / `state` and `onStateChange` are ignored.
|
||||
|
||||
## Server-Safe Import
|
||||
|
||||
Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:
|
||||
|
||||
```typescript
|
||||
import { schema, standardComponentDefinitions } from '@json-render/react-pdf/server';
|
||||
```
|
||||
|
||||
## Sub-path Exports
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Export</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>@json-render/react-pdf</code></td>
|
||||
<td>Full package: schema, renderer, components, render functions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@json-render/react-pdf/server</code></td>
|
||||
<td>Schema and catalog definitions only (no React)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@json-render/react-pdf/catalog</code></td>
|
||||
<td>Standard component definitions and types</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@json-render/react-pdf/render</code></td>
|
||||
<td>Server-side render functions only</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Types
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Export</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>ReactPdfSchema</code></td>
|
||||
<td>Schema type for PDF specs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ReactPdfSpec</code></td>
|
||||
<td>Spec type for PDF documents</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>RenderOptions</code></td>
|
||||
<td>Options for render functions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ComponentContext</code></td>
|
||||
<td>Typed component render function context</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ComponentFn</code></td>
|
||||
<td>Component render function type</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>StandardComponentDefinitions</code></td>
|
||||
<td>Type of the standard component definitions object</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>StandardComponentProps<K></code></td>
|
||||
<td>Inferred props type for a standard component by name</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "@json-render/react API" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/react")
|
||||
|
||||
# @json-render/react
|
||||
|
||||
@@ -9,11 +10,36 @@ React components, providers, and hooks.
|
||||
### StateProvider
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={object}>
|
||||
<StateProvider initialState={object} onStateChange={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:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react";
|
||||
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — React re-renders automatically:
|
||||
store.set("/count", 1);
|
||||
```
|
||||
|
||||
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||
|
||||
### ActionProvider
|
||||
|
||||
```tsx
|
||||
@@ -46,7 +72,9 @@ type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<b
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, and `loading` with catalog-inferred types.
|
||||
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.
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
@@ -87,10 +115,48 @@ type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
||||
interface ComponentContext<P> {
|
||||
props: P; // Typed props from catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit: (event: string) => void; // Emit a named event
|
||||
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 to check metadata like `shouldPreventDefault`:
|
||||
|
||||
```tsx
|
||||
Link: ({ props, on }) => {
|
||||
const click = on("click");
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(e) => {
|
||||
if (click.shouldPreventDefault) e.preventDefault();
|
||||
click.emit();
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
### BaseComponentProps
|
||||
|
||||
Catalog-agnostic base type for building reusable component libraries (e.g. `@json-render/shadcn`) that are not tied to a specific catalog:
|
||||
|
||||
```typescript
|
||||
import type { BaseComponentProps } from "@json-render/react";
|
||||
|
||||
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
|
||||
<div>{props.title}{children}</div>
|
||||
);
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "@json-render/remotion API" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/remotion")
|
||||
|
||||
# @json-render/remotion
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/shadcn")
|
||||
|
||||
# @json-render/shadcn
|
||||
|
||||
Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/shadcn @json-render/core @json-render/react zod
|
||||
```
|
||||
|
||||
Your app must have Tailwind CSS configured.
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Entry Point | Exports | Use For |
|
||||
|-------------|---------|---------|
|
||||
| `@json-render/shadcn` | `shadcnComponents` | React implementations |
|
||||
| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` | Catalog schemas (no React dependency, safe for server) |
|
||||
|
||||
## Usage
|
||||
|
||||
Pick the components you need from the standard definitions:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
|
||||
// Catalog: pick definitions
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
Input: shadcnComponentDefinitions.Input,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
// Registry: pick matching implementations
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Button: shadcnComponents.Button,
|
||||
Input: shadcnComponents.Input,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled by `ActionProvider` automatically. You don't need to declare them in your catalog.
|
||||
|
||||
## Extending with Custom Components
|
||||
|
||||
Add custom components alongside standard ones:
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Standard
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
|
||||
// Custom
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(["up", "down", "neutral"]).nullable(),
|
||||
}),
|
||||
description: "KPI metric display",
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Button: shadcnComponents.Button,
|
||||
Metric: ({ props }) => (
|
||||
<div>
|
||||
<span>{props.label}</span>
|
||||
<span>{props.value}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Available Components
|
||||
|
||||
### Layout
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Card` | Container card with optional title, description, maxWidth, centered |
|
||||
| `Stack` | Flex container with direction, gap, align, justify |
|
||||
| `Grid` | Grid layout with columns (1-6) and gap |
|
||||
| `Separator` | Visual separator line with orientation |
|
||||
|
||||
### Navigation
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Tabs` | Tabbed navigation with tabs array, defaultValue, value |
|
||||
| `Accordion` | Collapsible sections with items array and type (single/multiple) |
|
||||
| `Collapsible` | Single collapsible section with title and defaultOpen |
|
||||
| `Pagination` | Page navigation with totalPages and page |
|
||||
|
||||
### Overlay
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Dialog` | Modal dialog with title, description, openPath |
|
||||
| `Drawer` | Bottom drawer with title, description, openPath |
|
||||
| `Tooltip` | Hover tooltip with content and text |
|
||||
| `Popover` | Click-triggered popover with trigger and content |
|
||||
| `DropdownMenu` | Dropdown menu with label and items array |
|
||||
|
||||
### Content
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Heading` | Heading text with level (h1-h4) |
|
||||
| `Text` | Paragraph with variant (body, caption, muted, lead, code) |
|
||||
| `Image` | Image with alt, width, height |
|
||||
| `Avatar` | User avatar with src, name, size |
|
||||
| `Badge` | Status badge with text and variant |
|
||||
| `Alert` | Alert banner with title, message, type |
|
||||
| `Carousel` | Horizontally scrollable carousel with items |
|
||||
| `Table` | Data table with columns and rows |
|
||||
|
||||
### Feedback
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Progress` | Progress bar with value, max, label |
|
||||
| `Skeleton` | Loading placeholder with width, height, rounded |
|
||||
| `Spinner` | Loading spinner with size and label |
|
||||
|
||||
### Input
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Button` | Clickable button with label, variant, disabled |
|
||||
| `Link` | Anchor link with label and href |
|
||||
| `Input` | Text input with label, name, type, placeholder, value, checks |
|
||||
| `Textarea` | Multi-line text input with label, name, placeholder, rows, value, checks |
|
||||
| `Select` | Dropdown select with label, name, options, value, checks |
|
||||
| `Checkbox` | Checkbox with label, name, checked |
|
||||
| `Radio` | Radio button group with label, name, options, value |
|
||||
| `Switch` | Toggle switch with label, name, checked |
|
||||
| `Slider` | Range slider with label, min, max, step, value |
|
||||
| `Toggle` | Toggle button with label, pressed, variant |
|
||||
| `ToggleGroup` | Group of toggle buttons with items, type, value |
|
||||
| `ButtonGroup` | Group of buttons with buttons array and selected |
|
||||
|
||||
## Notes
|
||||
|
||||
- The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
|
||||
- Components use Tailwind CSS classes -- your app must have Tailwind configured
|
||||
- Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
|
||||
- Form inputs support `checks` for validation (type + message pairs)
|
||||
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Catalog" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/catalog")
|
||||
|
||||
# Catalog
|
||||
|
||||
@@ -18,7 +19,7 @@ A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defin
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react'; // or '@json-render/react-native'
|
||||
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
|
||||
@@ -1,9 +1,227 @@
|
||||
export const metadata = { title: "Changelog" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
# Changelog
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.9.1
|
||||
|
||||
February 2026
|
||||
|
||||
### Fixed: Install failure due to private dependency
|
||||
|
||||
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
|
||||
|
||||
---
|
||||
|
||||
## v0.9.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: External State Store
|
||||
|
||||
The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode.
|
||||
|
||||
- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
|
||||
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop
|
||||
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
|
||||
- When `store` is omitted, everything works exactly as before (fully backward compatible)
|
||||
- Applied across all platform packages: react, react-native, react-pdf
|
||||
- Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters
|
||||
|
||||
New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`.
|
||||
|
||||
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
|
||||
|
||||
### Changed: `onStateChange` signature updated (breaking)
|
||||
|
||||
The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
onStateChange?: (path: string, value: unknown) => void
|
||||
|
||||
// After
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void
|
||||
```
|
||||
|
||||
The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths.
|
||||
|
||||
### Fixed: Server-safe schema import
|
||||
|
||||
`@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`). All docs, examples, and skills now import `schema` from `@json-render/react/schema` instead of `@json-render/react`.
|
||||
|
||||
For combined imports, split into separate `schema` (subpath) and client API (main entry) lines:
|
||||
|
||||
```ts
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { defineRegistry, Renderer } from "@json-render/react";
|
||||
```
|
||||
|
||||
### Fixed: Chaining actions
|
||||
|
||||
Fixed an issue where chaining multiple actions on the same event (e.g. `setState` followed by a custom action) did not execute all actions. Affected `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf`.
|
||||
|
||||
### Fixed: Zod array inner type resolution
|
||||
|
||||
Fixed safely resolving the inner type for Zod arrays in schema introspection, preventing errors when catalog component props use `z.array()`.
|
||||
|
||||
---
|
||||
|
||||
## v0.8.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: `@json-render/react-pdf`
|
||||
|
||||
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react-pdf
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { renderToBuffer } from "@json-render/react-pdf";
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
const spec: Spec = {
|
||||
root: "doc",
|
||||
elements: {
|
||||
doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
|
||||
page: {
|
||||
type: "Page",
|
||||
props: { size: "A4" },
|
||||
children: ["heading", "table"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Invoice #1234", level: "h1" },
|
||||
children: [],
|
||||
},
|
||||
table: {
|
||||
type: "Table",
|
||||
props: {
|
||||
columns: [
|
||||
{ header: "Item", width: "60%" },
|
||||
{ header: "Price", width: "40%", align: "right" },
|
||||
],
|
||||
rows: [
|
||||
["Widget A", "$10.00"],
|
||||
["Widget B", "$25.00"],
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const buffer = await renderToBuffer(spec);
|
||||
```
|
||||
|
||||
Server-side rendering APIs:
|
||||
|
||||
- `renderToBuffer(spec)` -- render to an in-memory PDF buffer
|
||||
- `renderToStream(spec)` -- render to a readable stream (pipe to HTTP response)
|
||||
- `renderToFile(spec, path)` -- render directly to a file
|
||||
|
||||
15 standard components covering document structure (Document, Page), layout (View, Row, Column), content (Heading, Text, Image, Link), data (Table, List), decorative (Divider, Spacer), and page-level (PageNumber).
|
||||
|
||||
Supports custom catalogs with `defineRegistry`, server-safe imports via `@json-render/react-pdf/server`, and full context support (state, visibility, actions, validation, repeat scopes).
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: `@json-render/shadcn`
|
||||
|
||||
Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
|
||||
|
||||
```bash
|
||||
npm install @json-render/shadcn
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
Input: shadcnComponentDefinitions.Input,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Button: shadcnComponents.Button,
|
||||
Input: shadcnComponents.Input,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Components include: layout (Card, Stack, Grid, Separator), navigation (Tabs, Accordion, Collapsible, Pagination), overlay (Dialog, Drawer, Tooltip, Popover, DropdownMenu), content (Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table), feedback (Progress, Skeleton, Spinner), and input (Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup).
|
||||
|
||||
See the [API reference](/docs/api/shadcn) for full details.
|
||||
|
||||
### New: Event Handles (`on()`)
|
||||
|
||||
Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata:
|
||||
|
||||
- `emit()` -- fire the event
|
||||
- `shouldPreventDefault` -- whether any action binding requested `preventDefault`
|
||||
- `bound` -- whether any handler is bound to this event
|
||||
|
||||
```tsx
|
||||
Link: ({ props, on }) => {
|
||||
const click = on("click");
|
||||
return (
|
||||
<a href={props.href} onClick={(e) => {
|
||||
if (click.shouldPreventDefault) e.preventDefault();
|
||||
click.emit();
|
||||
}}>{props.label}</a>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
### New: `BaseComponentProps`
|
||||
|
||||
Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog.
|
||||
|
||||
```typescript
|
||||
import type { BaseComponentProps } from "@json-render/react";
|
||||
|
||||
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
|
||||
<div>{props.title}{children}</div>
|
||||
);
|
||||
```
|
||||
|
||||
### New: Built-in Actions in Schema
|
||||
|
||||
Schemas can now declare `builtInActions` -- actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`.
|
||||
|
||||
### New: `preventDefault` on `ActionBinding`
|
||||
|
||||
Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented.
|
||||
|
||||
### Improved: Stream Transform Text Block Splitting
|
||||
|
||||
`createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`.
|
||||
|
||||
### Improved: `defineRegistry` Actions Requirement
|
||||
|
||||
`defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0
|
||||
|
||||
February 2026
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Code Export" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/code-export")
|
||||
|
||||
# Code Export
|
||||
|
||||
@@ -134,6 +135,6 @@ Run the dashboard example and click "Export Project" to see code generation in a
|
||||
```bash
|
||||
cd examples/dashboard
|
||||
pnpm dev
|
||||
# Open http://localhost:3001
|
||||
# Open http://dashboard-demo.json-render.localhost:1355
|
||||
# Generate a widget, then click "Export Project"
|
||||
```
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/computed-values")
|
||||
|
||||
# Computed Values
|
||||
|
||||
Derive dynamic prop values using registered functions or string templates.
|
||||
|
||||
## `$template` — String Interpolation
|
||||
|
||||
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
|
||||
|
||||
Missing paths resolve to an empty string.
|
||||
|
||||
## `$computed` — Registered Functions
|
||||
|
||||
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": {
|
||||
"$computed": "fullName",
|
||||
"args": {
|
||||
"first": { "$state": "/form/firstName" },
|
||||
"last": { "$state": "/form/lastName" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Registering Functions
|
||||
|
||||
Functions are registered in the catalog and provided at runtime.
|
||||
|
||||
**Catalog definition (for AI prompt generation):**
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
functions: {
|
||||
fullName: {
|
||||
description: 'Combines first and last name into a full name',
|
||||
},
|
||||
formatCurrency: {
|
||||
description: 'Formats a number as currency',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Runtime implementation:**
|
||||
|
||||
```tsx
|
||||
import { JSONUIProvider } from '@json-render/react';
|
||||
|
||||
const functions = {
|
||||
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
|
||||
formatCurrency: (args) => {
|
||||
const value = Number(args.value ?? 0);
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: (args.currency as string) ?? 'USD',
|
||||
}).format(value);
|
||||
},
|
||||
};
|
||||
|
||||
<JSONUIProvider registry={registry} functions={functions}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
### Using with `createRenderer`
|
||||
|
||||
```tsx
|
||||
const MyRenderer = createRenderer(catalog, components);
|
||||
|
||||
<MyRenderer
|
||||
spec={spec}
|
||||
functions={functions}
|
||||
/>
|
||||
```
|
||||
|
||||
## Combining Expressions
|
||||
|
||||
`$computed` args can use any expression type. This example computes a total from repeat item fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"$computed": "lineTotal",
|
||||
"args": {
|
||||
"price": { "$item": "price" },
|
||||
"quantity": { "$item": "quantity" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
- [Watchers](/docs/watchers) — react to state changes with cascading actions
|
||||
- [Data Binding](/docs/data-binding) — all expression types
|
||||
- [Validation](/docs/validation) — validate form inputs
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Custom Schema & Renderer" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/custom-schema")
|
||||
|
||||
# Custom Schema & Renderer
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Data Binding" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/data-binding")
|
||||
|
||||
# Data Binding
|
||||
|
||||
@@ -212,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
|
||||
|
||||
The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
|
||||
## Template Strings
|
||||
|
||||
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": { "$template": "Welcome back, ${/user/name}!" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
@@ -254,10 +271,39 @@ The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
<td><code>{'{ "$bindItem": "field" }'}</code></td>
|
||||
<td>Form components inside repeat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$template"}</code></td>
|
||||
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
|
||||
<td>Anywhere (string props)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$computed"}</code></td>
|
||||
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
|
||||
<td>Anywhere (requires registered function)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## External Store (Controlled Mode)
|
||||
|
||||
For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react";
|
||||
|
||||
const store = createStateStore({ user: { name: "Alice" } });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — React re-renders automatically:
|
||||
store.set("/user/name", "Bob");
|
||||
```
|
||||
|
||||
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface.
|
||||
|
||||
## Next
|
||||
|
||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Generation Modes" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/generation-modes")
|
||||
|
||||
# Generation Modes
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Installation" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/installation")
|
||||
|
||||
# Installation
|
||||
|
||||
@@ -8,6 +9,14 @@ Install the core package plus your renderer of choice.
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react" />
|
||||
|
||||
## For React UI with shadcn/ui
|
||||
|
||||
Pre-built components for fast prototyping and production use:
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react @json-render/shadcn" />
|
||||
|
||||
Requires Tailwind CSS in your project. See the [@json-render/shadcn API reference](/docs/api/shadcn) for usage.
|
||||
|
||||
## For React Native
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react-native" />
|
||||
@@ -16,6 +25,18 @@ Install the core package plus your renderer of choice.
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
|
||||
|
||||
## For External State Management (Optional)
|
||||
|
||||
If you want to wire json-render to an existing state management library instead of the built-in store, install the adapter for your library:
|
||||
|
||||
<PackageInstall packages="@json-render/zustand" />
|
||||
|
||||
<PackageInstall packages="@json-render/redux" />
|
||||
|
||||
<PackageInstall packages="@json-render/jotai" />
|
||||
|
||||
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
|
||||
|
||||
## Peer Dependencies
|
||||
|
||||
json-render requires the following peer dependencies:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Migration Guide" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/migration")
|
||||
|
||||
# Migration Guide
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "OpenAPI Integration" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/openapi")
|
||||
|
||||
# OpenAPI Integration
|
||||
|
||||
@@ -99,7 +100,7 @@ Create components that map to OpenAPI data types:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const openapiCatalog = defineCatalog(schema, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Introduction" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs")
|
||||
|
||||
# Introduction
|
||||
|
||||
@@ -22,7 +23,7 @@ A catalog declares what AI can use: components with typed props, actions with ty
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Quick Start" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/quick-start")
|
||||
|
||||
# Quick Start
|
||||
|
||||
@@ -11,7 +12,7 @@ Create a catalog that defines what components AI can use:
|
||||
```typescript
|
||||
// lib/catalog.ts
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
@@ -163,9 +164,51 @@ export default function Page() {
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Start with shadcn/ui
|
||||
|
||||
If you want to skip defining components from scratch, use `@json-render/shadcn` for 36 pre-built components:
|
||||
|
||||
```typescript
|
||||
// lib/catalog.ts
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
Input: shadcnComponentDefinitions.Input,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// lib/registry.tsx
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
import { shadcnComponents } from '@json-render/shadcn';
|
||||
import { catalog } from './catalog';
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Button: shadcnComponents.Button,
|
||||
Input: shadcnComponents.Input,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Learn about [catalogs](/docs/catalog) in depth
|
||||
- Explore [data binding](/docs/data-binding) for dynamic values
|
||||
- Add [action handlers](/docs/registry#action-handlers) for interactivity
|
||||
- Implement [conditional visibility](/docs/visibility)
|
||||
- Use [pre-built shadcn/ui components](/docs/api/shadcn) for fast prototyping
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Registry" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/registry")
|
||||
|
||||
# Registry
|
||||
|
||||
@@ -69,14 +70,40 @@ Each component receives a `ComponentContext` object:
|
||||
interface ComponentContext {
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit: (event: string) => void; // Emit a named event (e.g. "press")
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
|
||||
|
||||
Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata:
|
||||
|
||||
```tsx
|
||||
Link: ({ props, on }) => {
|
||||
const click = on("click");
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(e) => {
|
||||
if (click.shouldPreventDefault) e.preventDefault();
|
||||
click.emit();
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
#### Using `bindings` for two-way binding
|
||||
|
||||
When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together:
|
||||
@@ -106,7 +133,7 @@ Actions are declared in your [catalog](/docs/catalog). The `@json-render/react`
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Schemas" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/schemas")
|
||||
|
||||
# Schemas
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Specs" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/specs")
|
||||
|
||||
# Specs
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Streaming" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/streaming")
|
||||
|
||||
# Streaming
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Validation" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/validation")
|
||||
|
||||
# Validation
|
||||
|
||||
@@ -10,11 +11,18 @@ json-render includes common validation functions:
|
||||
|
||||
- `required` — Value must be non-empty
|
||||
- `email` — Valid email format
|
||||
- `minLength` — Minimum string length
|
||||
- `maxLength` — Maximum string length
|
||||
- `pattern` — Match a regex pattern
|
||||
- `min` — Minimum numeric value
|
||||
- `max` — Maximum numeric value
|
||||
- `minLength` — Minimum string length (args: `{ "min": N }`)
|
||||
- `maxLength` — Maximum string length (args: `{ "max": N }`)
|
||||
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
|
||||
- `min` — Minimum numeric value (args: `{ "min": N }`)
|
||||
- `max` — Maximum numeric value (args: `{ "max": N }`)
|
||||
- `numeric` — Value must be a number
|
||||
- `url` — Valid URL format
|
||||
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
|
||||
|
||||
## Using Validation in JSON
|
||||
|
||||
@@ -66,7 +74,7 @@ Define custom validators in your catalog's `functions` field. The catalog itself
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react'; // or '@json-render/react-native'
|
||||
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
@@ -142,14 +150,114 @@ function TextField({ props, bindings }) {
|
||||
|
||||
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
|
||||
|
||||
## Cross-Field Validation
|
||||
|
||||
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": {
|
||||
"label": "Confirm Password",
|
||||
"value": { "$bindState": "/form/confirmPassword" },
|
||||
"checks": [
|
||||
{ "type": "required", "message": "Please confirm your password" },
|
||||
{
|
||||
"type": "matches",
|
||||
"args": { "other": { "$state": "/form/password" } },
|
||||
"message": "Passwords must match"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Other cross-field examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"type": "greaterThan",
|
||||
"args": { "other": { "$state": "/form/startDate" } },
|
||||
"message": "End date must be after start date"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"type": "requiredIf",
|
||||
"args": { "field": { "$state": "/form/enableNotifications" } },
|
||||
"message": "Email is required when notifications are enabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Validation
|
||||
|
||||
Use the `enabled` field in the validation config to only run checks when a condition is met:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": {
|
||||
"label": "Company Name",
|
||||
"value": { "$bindState": "/form/company" },
|
||||
"checks": [
|
||||
{ "type": "required", "message": "Company name is required" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the component implementation, you can pass `enabled` to `useFieldValidation`:
|
||||
|
||||
```typescript
|
||||
useFieldValidation(bindings?.value ?? "", {
|
||||
checks: props.checks ?? [],
|
||||
enabled: { "$state": "/form/accountType", eq: "business" },
|
||||
});
|
||||
```
|
||||
|
||||
This only validates the company name when the account type is "business".
|
||||
|
||||
## Validation Timing
|
||||
|
||||
Control when validation runs with `validateOn`:
|
||||
|
||||
- `change` — Validate on every input change
|
||||
- `blur` — Validate when field loses focus
|
||||
- `blur` — Validate when field loses focus (default for Input, Textarea)
|
||||
- `submit` — Validate only on form submission
|
||||
|
||||
## Form-Level Validation
|
||||
|
||||
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Button",
|
||||
"props": { "label": "Submit" },
|
||||
"on": {
|
||||
"press": [
|
||||
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||
{ "action": "submitForm" }
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
|
||||
|
||||
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [generation modes](/docs/generation-modes).
|
||||
- [Computed Values](/docs/computed-values) — derive dynamic prop values
|
||||
- [Watchers](/docs/watchers) — react to state changes
|
||||
- [Generation Modes](/docs/generation-modes) — how AI generates specs
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const metadata = { title: "Visibility" }
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/visibility")
|
||||
|
||||
# Visibility
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/watchers")
|
||||
|
||||
# Watchers
|
||||
|
||||
React to state changes by triggering actions when watched paths update.
|
||||
|
||||
## The `watch` Field
|
||||
|
||||
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
|
||||
|
||||
`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "Country",
|
||||
"value": { "$bindState": "/form/country" },
|
||||
"options": ["US", "Canada", "UK"]
|
||||
},
|
||||
"watch": {
|
||||
"/form/country": {
|
||||
"action": "loadCities",
|
||||
"params": { "country": { "$state": "/form/country" } }
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
|
||||
|
||||
## Cascading Selects
|
||||
|
||||
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "form",
|
||||
"elements": {
|
||||
"form": {
|
||||
"type": "Stack",
|
||||
"props": { "direction": "vertical", "gap": "md" },
|
||||
"children": ["country-select", "city-select"]
|
||||
},
|
||||
"country-select": {
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "Country",
|
||||
"value": { "$bindState": "/form/country" },
|
||||
"options": ["US", "Canada", "UK"]
|
||||
},
|
||||
"watch": {
|
||||
"/form/country": [
|
||||
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
|
||||
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
"city-select": {
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "City",
|
||||
"value": { "$bindState": "/form/city" },
|
||||
"options": { "$state": "/availableCities" },
|
||||
"placeholder": "Select a city"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"form": { "country": "", "city": "" },
|
||||
"availableCities": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The watcher on `country-select` fires two actions when the country changes:
|
||||
1. `loadCities` — fetches and writes city options to `/availableCities`
|
||||
2. `setState` — resets the city selection
|
||||
|
||||
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
|
||||
|
||||
### Action Handler
|
||||
|
||||
```typescript
|
||||
const handlers = {
|
||||
loadCities: async (params) => {
|
||||
const cities = await fetchCities(params.country);
|
||||
// setState is called by the runtime to write the result
|
||||
return cities;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Or with `defineRegistry`:
|
||||
|
||||
```typescript
|
||||
const { registry, handlers } = defineRegistry(catalog, {
|
||||
components: { /* ... */ },
|
||||
actions: {
|
||||
loadCities: async (params, setState) => {
|
||||
const response = await fetch(`/api/cities?country=${params.country}`);
|
||||
const cities = await response.json();
|
||||
setState('/availableCities', cities);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Multiple Watchers
|
||||
|
||||
An element can watch multiple state paths. Each path maps to one or more action bindings:
|
||||
|
||||
```json
|
||||
{
|
||||
"watch": {
|
||||
"/form/startDate": { "action": "validateDateRange" },
|
||||
"/form/endDate": { "action": "validateDateRange" },
|
||||
"/form/quantity": [
|
||||
{ "action": "recalculateTotal" },
|
||||
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Watchers only fire on **value changes**, not on the initial render
|
||||
- Comparison is by reference (`===`), not deep equality
|
||||
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
|
||||
- Multiple action bindings on the same path execute sequentially
|
||||
|
||||
## When to Use `watch` vs `on`
|
||||
|
||||
| Mechanism | Trigger | Use Case |
|
||||
|-----------|---------|----------|
|
||||
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
|
||||
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
|
||||
|
||||
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
|
||||
|
||||
## Next
|
||||
|
||||
- [Data Binding](/docs/data-binding) — connect elements to state
|
||||
- [Computed Values](/docs/computed-values) — derive prop values
|
||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||
@@ -154,23 +154,34 @@ button {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* MDX table styles — fallback for GFM-generated tables */
|
||||
/* MDX table styles — applies to both GFM pipe tables and raw HTML tables */
|
||||
.mdx-table th,
|
||||
.mdx-table td {
|
||||
.mdx-table td,
|
||||
article table th,
|
||||
article table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mdx-table th {
|
||||
.mdx-table th,
|
||||
article table th {
|
||||
font-weight: 600;
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.mdx-table td {
|
||||
.mdx-table td,
|
||||
article table td {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
article table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Shiki dual theme support */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { GeistPixelSquare } from "geist/font/pixel";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
@@ -61,7 +62,6 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
images: ["/og"],
|
||||
creator: "@verabornnot",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
@@ -92,7 +92,9 @@ export default async function RootLayout({
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
|
||||
@@ -5,19 +5,20 @@ import { join } from "node:path";
|
||||
export { getPageTitle } from "@/lib/page-titles";
|
||||
|
||||
// Cache font data in memory after first load
|
||||
let fontCache: { geistRegular: Buffer } | null = null;
|
||||
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null = null;
|
||||
|
||||
async function loadFonts() {
|
||||
if (fontCache) return fontCache;
|
||||
const geistRegular = await readFile(
|
||||
join(process.cwd(), "public/Geist-Regular.ttf"),
|
||||
);
|
||||
fontCache = { geistRegular };
|
||||
const [geistRegular, geistPixelSquare] = await Promise.all([
|
||||
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
|
||||
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
|
||||
]);
|
||||
fontCache = { geistRegular, geistPixelSquare };
|
||||
return fontCache;
|
||||
}
|
||||
|
||||
export async function renderOgImage(title: string) {
|
||||
const { geistRegular } = await loadFonts();
|
||||
const { geistRegular, geistPixelSquare } = await loadFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
@@ -53,8 +54,8 @@ export async function renderOgImage(title: string) {
|
||||
<span
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontFamily: "Geist",
|
||||
fontWeight: 400,
|
||||
fontFamily: "Geist Pixel Square",
|
||||
fontWeight: 500,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
@@ -99,6 +100,12 @@ export async function renderOgImage(title: string) {
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: "Geist Pixel Square",
|
||||
data: geistPixelSquare.buffer as ArrayBuffer,
|
||||
style: "normal",
|
||||
weight: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Playground } from "@/components/playground";
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
|
||||
export const metadata = {
|
||||
title: PAGE_TITLES["playground"],
|
||||
};
|
||||
export const metadata = pageMetadata("playground");
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
return <Playground />;
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Header() {
|
||||
</svg>
|
||||
</span>
|
||||
<Link href="/">
|
||||
<span className="font-medium tracking-tight text-lg">
|
||||
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
|
||||
json-render
|
||||
</span>
|
||||
</Link>
|
||||
@@ -100,7 +100,7 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>10.5k</span>
|
||||
<span>11k</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
@@ -16,17 +16,20 @@ import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { Header } from "./header";
|
||||
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
||||
import { JsonEditor } from "@visual-json/react";
|
||||
import type { JsonValue } from "@visual-json/react";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||
|
||||
type Tab = "json" | "nested" | "stream" | "catalog";
|
||||
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
|
||||
type RenderView = "preview" | "code";
|
||||
type MobileView =
|
||||
| "json"
|
||||
| "nested"
|
||||
| "stream"
|
||||
| "catalog"
|
||||
| "visual"
|
||||
| "preview"
|
||||
| "generated-code";
|
||||
|
||||
@@ -233,6 +236,20 @@ export function Playground() {
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
const handleVisualChange = useCallback(
|
||||
(value: JsonValue) => {
|
||||
if (!selectedVersionId || isStreaming) return;
|
||||
setVersions((prev) =>
|
||||
prev.map((v) =>
|
||||
v.id === selectedVersionId
|
||||
? { ...v, tree: value as unknown as Spec }
|
||||
: v,
|
||||
),
|
||||
);
|
||||
},
|
||||
[selectedVersionId, isStreaming],
|
||||
);
|
||||
|
||||
const jsonCode = currentTree
|
||||
? JSON.stringify(currentTree, null, 2)
|
||||
: "// waiting...";
|
||||
@@ -479,31 +496,69 @@ ${jsx}
|
||||
? jsonCode
|
||||
: activeTab === "nested"
|
||||
? nestedCode
|
||||
: "";
|
||||
: activeTab === "visual"
|
||||
? jsonCode
|
||||
: "";
|
||||
|
||||
const codePane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
||||
(tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{activeTab !== "catalog" && (
|
||||
{activeTab !== "catalog" && activeTab !== "visual" && (
|
||||
<CopyButton text={copyText} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "catalog" ? (
|
||||
{activeTab === "visual" ? (
|
||||
currentTree ? (
|
||||
<JsonEditor
|
||||
value={currentTree as unknown as JsonValue}
|
||||
onChange={handleVisualChange}
|
||||
readOnly={isStreaming}
|
||||
sidebarOpen={false}
|
||||
height="100%"
|
||||
className="h-full"
|
||||
style={
|
||||
{
|
||||
"--vj-bg": "var(--background)",
|
||||
"--vj-bg-panel": "var(--background)",
|
||||
"--vj-bg-hover": "var(--muted)",
|
||||
"--vj-bg-selected": "var(--primary)",
|
||||
"--vj-bg-selected-muted": "var(--muted)",
|
||||
"--vj-text": "var(--foreground)",
|
||||
"--vj-text-selected": "var(--primary-foreground)",
|
||||
"--vj-text-muted": "var(--muted-foreground)",
|
||||
"--vj-text-dim": "var(--muted-foreground)",
|
||||
"--vj-border": "var(--border)",
|
||||
"--vj-border-subtle": "var(--border)",
|
||||
"--vj-accent": "var(--primary)",
|
||||
"--vj-accent-muted": "var(--muted)",
|
||||
"--vj-input-bg": "var(--secondary)",
|
||||
"--vj-input-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||
{"// generate a spec to edit visually"}
|
||||
</div>
|
||||
)
|
||||
) : activeTab === "catalog" ? (
|
||||
<div className="h-full flex flex-col text-sm">
|
||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||
{(
|
||||
@@ -735,19 +790,21 @@ ${jsx}
|
||||
: 0}
|
||||
</button>
|
||||
{/* Code tabs */}
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setMobileView(tab)}
|
||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||
mobileView === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
||||
(tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setMobileView(tab)}
|
||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||
mobileView === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* Preview / code toggle */}
|
||||
{[
|
||||
@@ -770,7 +827,41 @@ ${jsx}
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{mobileView === "catalog" ? (
|
||||
{mobileView === "visual" ? (
|
||||
currentTree ? (
|
||||
<JsonEditor
|
||||
value={currentTree as unknown as JsonValue}
|
||||
onChange={handleVisualChange}
|
||||
readOnly={isStreaming}
|
||||
sidebarOpen={false}
|
||||
height="100%"
|
||||
className="h-full"
|
||||
style={
|
||||
{
|
||||
"--vj-bg": "var(--background)",
|
||||
"--vj-bg-panel": "var(--background)",
|
||||
"--vj-bg-hover": "var(--muted)",
|
||||
"--vj-bg-selected": "var(--primary)",
|
||||
"--vj-bg-selected-muted": "var(--muted)",
|
||||
"--vj-text": "var(--foreground)",
|
||||
"--vj-text-selected": "var(--primary-foreground)",
|
||||
"--vj-text-muted": "var(--muted-foreground)",
|
||||
"--vj-text-dim": "var(--muted-foreground)",
|
||||
"--vj-border": "var(--border)",
|
||||
"--vj-border-subtle": "var(--border)",
|
||||
"--vj-accent": "var(--primary)",
|
||||
"--vj-accent-muted": "var(--muted)",
|
||||
"--vj-input-bg": "var(--secondary)",
|
||||
"--vj-input-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||
{"// generate a spec to edit visually"}
|
||||
</div>
|
||||
)
|
||||
) : mobileView === "catalog" ? (
|
||||
<div className="h-full flex flex-col text-sm">
|
||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||
{(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
@@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "Schemas", href: "/docs/schemas" },
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Computed Values", href: "/docs/computed-values" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Watchers", href: "/docs/watchers" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
],
|
||||
},
|
||||
@@ -57,6 +59,11 @@ export const docsNavigation: NavSection[] = [
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "React PDF",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Remotion",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||
@@ -86,6 +93,8 @@ export const docsNavigation: NavSection[] = [
|
||||
items: [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
|
||||
{ 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/codegen", href: "/docs/api/codegen" },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PAGE_TITLES } from "./page-titles";
|
||||
|
||||
const DESCRIPTION =
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.";
|
||||
|
||||
export function pageMetadata(slug: string): Metadata {
|
||||
const title = PAGE_TITLES[slug];
|
||||
if (!title) return {};
|
||||
|
||||
const displayTitle = title.replace(/\n/g, " ");
|
||||
const fullTitle = `${displayTitle} | json-render`;
|
||||
const ogImageUrl = slug ? `/og/${slug}` : "/og";
|
||||
|
||||
return {
|
||||
title: displayTitle,
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
siteName: "json-render",
|
||||
title: fullTitle,
|
||||
description: DESCRIPTION,
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `${displayTitle} - json-render`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: fullTitle,
|
||||
description: DESCRIPTION,
|
||||
images: [ogImageUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,9 +38,11 @@ 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/react-pdf": "@json-render/react-pdf API",
|
||||
"docs/api/react-native": "@json-render/react-native API",
|
||||
"docs/api/codegen": "@json-render/codegen API",
|
||||
"docs/api/remotion": "@json-render/remotion API",
|
||||
"docs/api/shadcn": "@json-render/shadcn API",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,4 +22,6 @@ const nextConfig = {
|
||||
|
||||
const withMDX = createMDX({});
|
||||
|
||||
export default withMDX(nextConfig);
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = withMDX(nextConfig);
|
||||
export default config;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "portless json-render next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
@@ -28,11 +28,13 @@
|
||||
"@upstash/redis": "^1.36.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@visual-json/react": "0.1.1",
|
||||
"ai": "^6.0.33",
|
||||
"bash-tool": "1.3.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"geist": "1.7.0",
|
||||
"just-bash": "2.9.6",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
@@ -51,8 +53,8 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^22.15.3",
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/nextjs.json",
|
||||
"extends": "@internal/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# example-chat
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/shadcn@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1d755c1]
|
||||
- @json-render/core@0.9.0
|
||||
- @json-render/react@0.9.0
|
||||
- @json-render/shadcn@0.9.0
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
@@ -45,48 +46,19 @@ const mesh3DProps = {
|
||||
*/
|
||||
export const explorerCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Layout
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
wrap: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex layout container",
|
||||
example: { direction: "vertical", gap: "md", wrap: null },
|
||||
},
|
||||
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Card container with optional title and description",
|
||||
example: { title: "Weather", description: "Current conditions" },
|
||||
},
|
||||
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z.enum(["1", "2", "3", "4"]).nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Responsive grid layout container",
|
||||
example: { columns: "3", gap: "md" },
|
||||
},
|
||||
|
||||
// Typography
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
}),
|
||||
description: "Section heading",
|
||||
example: { text: "Data Explorer", level: "h1" },
|
||||
},
|
||||
// From @json-render/shadcn (used as-is)
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Grid: shadcnComponentDefinitions.Grid,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Separator: shadcnComponentDefinitions.Separator,
|
||||
Accordion: shadcnComponentDefinitions.Accordion,
|
||||
Progress: shadcnComponentDefinitions.Progress,
|
||||
Skeleton: shadcnComponentDefinitions.Skeleton,
|
||||
Badge: shadcnComponentDefinitions.Badge,
|
||||
Alert: shadcnComponentDefinitions.Alert,
|
||||
|
||||
// Chat-specific components (different schemas or fully custom)
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
@@ -96,32 +68,6 @@ export const explorerCatalog = defineCatalog(schema, {
|
||||
example: { content: "Here is your data overview." },
|
||||
},
|
||||
|
||||
// Data display
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "destructive", "outline"])
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
example: { text: "Live", variant: "default" },
|
||||
},
|
||||
|
||||
Alert: {
|
||||
props: z.object({
|
||||
variant: z.enum(["default", "destructive"]).nullable(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
description: "Alert or info message",
|
||||
},
|
||||
|
||||
Separator: {
|
||||
props: z.object({}),
|
||||
description: "Visual divider",
|
||||
},
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
@@ -222,22 +168,6 @@ export const explorerCatalog = defineCatalog(schema, {
|
||||
description: "Content for a specific tab",
|
||||
},
|
||||
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
}),
|
||||
description: "Progress bar",
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
props: z.object({
|
||||
width: z.string().nullable(),
|
||||
height: z.string().nullable(),
|
||||
}),
|
||||
description: "Loading placeholder",
|
||||
},
|
||||
|
||||
// Educational / Rich content
|
||||
Callout: {
|
||||
props: z.object({
|
||||
@@ -254,24 +184,6 @@ export const explorerCatalog = defineCatalog(schema, {
|
||||
},
|
||||
},
|
||||
|
||||
Accordion: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
),
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Collapsible accordion sections for organizing detailed content",
|
||||
example: {
|
||||
items: [{ title: "Overview", content: "A brief introduction." }],
|
||||
type: "multiple",
|
||||
},
|
||||
},
|
||||
|
||||
Timeline: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useRef, type ReactNode } from "react";
|
||||
import { useBoundProp, defineRegistry } from "@json-render/react";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
@@ -20,16 +21,6 @@ import {
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
@@ -39,14 +30,6 @@ import {
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Accordion as AccordionRoot,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
@@ -218,77 +201,25 @@ function AnimatedGroup({
|
||||
|
||||
export const { registry, handlers } = defineRegistry(explorerCatalog, {
|
||||
components: {
|
||||
Stack: ({ props, children }) => {
|
||||
const gapClass =
|
||||
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
|
||||
return (
|
||||
<div
|
||||
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${props.wrap ? "flex-wrap" : ""} ${gapClass}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Card: ({ props, children }) => (
|
||||
<Card>
|
||||
{(props.title || props.description) && (
|
||||
<CardHeader>
|
||||
{props.title && <CardTitle>{props.title}</CardTitle>}
|
||||
{props.description && (
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className="flex flex-col gap-4">{children}</CardContent>
|
||||
</Card>
|
||||
),
|
||||
|
||||
Grid: ({ props, children }) => {
|
||||
const colsClass =
|
||||
{
|
||||
"1": "grid-cols-1",
|
||||
"2": "grid-cols-1 md:grid-cols-2",
|
||||
"3": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
||||
"4": "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
|
||||
}[props.columns ?? "3"] ?? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
|
||||
const gapClass =
|
||||
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
|
||||
return <div className={`grid ${colsClass} ${gapClass}`}>{children}</div>;
|
||||
},
|
||||
|
||||
Heading: ({ props }) => {
|
||||
const Tag = (props.level ?? "h2") as "h1" | "h2" | "h3" | "h4";
|
||||
const sizeClass = {
|
||||
h1: "text-3xl font-bold tracking-tight",
|
||||
h2: "text-2xl font-semibold tracking-tight",
|
||||
h3: "text-xl font-semibold",
|
||||
h4: "text-lg font-medium",
|
||||
}[props.level ?? "h2"];
|
||||
return <Tag className={sizeClass}>{props.text}</Tag>;
|
||||
},
|
||||
// From @json-render/shadcn (used as-is)
|
||||
Stack: shadcnComponents.Stack,
|
||||
Card: shadcnComponents.Card,
|
||||
Grid: shadcnComponents.Grid,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Separator: shadcnComponents.Separator,
|
||||
Accordion: shadcnComponents.Accordion,
|
||||
Progress: shadcnComponents.Progress,
|
||||
Skeleton: shadcnComponents.Skeleton,
|
||||
Badge: shadcnComponents.Badge,
|
||||
Alert: shadcnComponents.Alert,
|
||||
|
||||
// Chat-specific components
|
||||
Text: ({ props }) => (
|
||||
<p className={props.muted ? "text-muted-foreground" : ""}>
|
||||
{props.content}
|
||||
</p>
|
||||
),
|
||||
|
||||
Badge: ({ props }) => (
|
||||
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
|
||||
),
|
||||
|
||||
Alert: ({ props }) => (
|
||||
<Alert variant={props.variant ?? "default"}>
|
||||
<AlertTitle>{props.title}</AlertTitle>
|
||||
{props.description ? (
|
||||
<AlertDescription>{props.description}</AlertDescription>
|
||||
) : null}
|
||||
</Alert>
|
||||
),
|
||||
|
||||
Separator: () => <Separator />,
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const TrendIcon =
|
||||
props.trend === "up"
|
||||
@@ -566,16 +497,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
|
||||
<TabsContent value={props.value}>{children}</TabsContent>
|
||||
),
|
||||
|
||||
Progress: ({ props }) => (
|
||||
<Progress value={props.value} max={props.max ?? 100} />
|
||||
),
|
||||
|
||||
Skeleton: ({ props }) => (
|
||||
<Skeleton
|
||||
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
|
||||
/>
|
||||
),
|
||||
|
||||
Callout: ({ props }) => {
|
||||
const config = {
|
||||
info: {
|
||||
@@ -626,23 +547,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Accordion: ({ props }) => (
|
||||
<AccordionRoot
|
||||
type={props.type === "single" ? "single" : "multiple"}
|
||||
collapsible={props.type === "single" ? true : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
{(props.items ?? []).map((item, i) => (
|
||||
<AccordionItem key={i} value={`item-${i}`}>
|
||||
<AccordionTrigger>{item.title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="text-muted-foreground">{item.content}</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</AccordionRoot>
|
||||
),
|
||||
|
||||
Timeline: ({ props }) => (
|
||||
<div className="relative pl-8">
|
||||
{/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
|
||||
@@ -987,8 +891,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
|
||||
</DreiText>
|
||||
),
|
||||
},
|
||||
|
||||
actions: {},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "portless chat-demo.json-render next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
@@ -15,6 +15,7 @@
|
||||
"@ai-sdk/react": "^3.0.84",
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@json-render/shadcn": "workspace:*",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@streamdown/code": "^1.0.2",
|
||||
@@ -35,7 +36,7 @@
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# example-dashboard
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/codegen@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1d755c1]
|
||||
- @json-render/core@0.9.0
|
||||
- @json-render/react@0.9.0
|
||||
- @json-render/codegen@0.9.0
|
||||
@@ -196,24 +196,28 @@ export function Widget({
|
||||
onDeleted?.();
|
||||
}, [widgetId, onDeleted]);
|
||||
|
||||
const handleStateChange = useCallback((path: string, value: unknown) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev };
|
||||
// Convert path like "customerForm/name" to nested object
|
||||
const parts = path.split("/");
|
||||
let current: Record<string, unknown> = next;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!;
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
const handleStateChange = useCallback(
|
||||
(changes: Array<{ path: string; value: unknown }>) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const { path, value } of changes) {
|
||||
const parts = path.split("/");
|
||||
let current: Record<string, unknown> = next;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!;
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
current[lastPart] = value;
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
current[lastPart] = value;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Use spec from stream, or initial spec for saved widgets
|
||||
const currentSpec = spec || initialSpec;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
@@ -24,7 +24,7 @@ interface DashboardRendererProps {
|
||||
spec: Spec | null;
|
||||
state?: Record<string, unknown>;
|
||||
setState?: SetState;
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "portless dashboard-demo.json-render next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
@@ -42,7 +42,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# example-no-ai
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/shadcn@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1d755c1]
|
||||
- @json-render/core@0.9.0
|
||||
- @json-render/react@0.9.0
|
||||
- @json-render/shadcn@0.9.0
|
||||
@@ -0,0 +1,105 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../../../packages/shadcn/src/**/*.tsx";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "json-render No-AI Example",
|
||||
description: "Static JSON specs rendered with json-render -- no AI required",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import { JSONUIProvider, Renderer } from "@json-render/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
registry,
|
||||
actionHandlers,
|
||||
computedFunctions,
|
||||
onConfetti,
|
||||
} from "@/lib/render/registry";
|
||||
import { examples } from "@/lib/examples";
|
||||
|
||||
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
|
||||
return (
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={spec.state ?? {}}
|
||||
handlers={actionHandlers}
|
||||
functions={computedFunctions}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selected = examples[selectedIndex]!;
|
||||
const [confettiKey, setConfettiKey] = useState(0);
|
||||
const [confettiActive, setConfettiActive] = useState(false);
|
||||
|
||||
const fireConfetti = useCallback(() => {
|
||||
setConfettiKey((k) => k + 1);
|
||||
setConfettiActive(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-muted/30">
|
||||
{/* Example selector */}
|
||||
<nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
|
||||
{examples.map((ex, i) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => setSelectedIndex(i)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
|
||||
i === selectedIndex
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{ex.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Render area */}
|
||||
<div className="flex-1 flex items-start justify-center overflow-auto p-6">
|
||||
<div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
|
||||
{confettiActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<ConfettiExplosion
|
||||
key={confettiKey}
|
||||
portal={false}
|
||||
force={0.8}
|
||||
duration={3500}
|
||||
particleCount={400}
|
||||
particleSize={8}
|
||||
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
|
||||
width={1600}
|
||||
height="200vh"
|
||||
zIndex={1}
|
||||
onComplete={() => setConfettiActive(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 relative z-10">
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{selected.description}
|
||||
</p>
|
||||
<SpecRenderer key={selectedIndex} spec={selected.spec} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
"react/prop-types": "off",
|
||||
"react/no-unknown-property": ["error", { ignore: ["jsx"] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,860 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export interface Example {
|
||||
name: string;
|
||||
description: string;
|
||||
spec: Spec;
|
||||
}
|
||||
|
||||
export const examples: Example[] = [
|
||||
{
|
||||
name: "Confetti",
|
||||
description: "A button that fires confetti when clicked",
|
||||
spec: {
|
||||
root: "btn",
|
||||
elements: {
|
||||
btn: {
|
||||
type: "Button",
|
||||
props: { label: "Click me", variant: "primary" },
|
||||
on: { press: { action: "confetti" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
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: "React", 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, switches, and checkboxes",
|
||||
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",
|
||||
maxWidth: null,
|
||||
centered: null,
|
||||
},
|
||||
children: ["freeContent"],
|
||||
},
|
||||
freeContent: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
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", disabled: null },
|
||||
},
|
||||
pro: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Pro",
|
||||
description: "$19/month",
|
||||
maxWidth: null,
|
||||
centered: null,
|
||||
},
|
||||
children: ["proContent"],
|
||||
},
|
||||
proContent: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
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",
|
||||
disabled: null,
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Enterprise",
|
||||
description: "Custom pricing",
|
||||
maxWidth: null,
|
||||
centered: null,
|
||||
},
|
||||
children: ["entContent"],
|
||||
},
|
||||
entContent: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
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",
|
||||
disabled: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
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",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Feature Comparison",
|
||||
description: "Tables inside an accordion for comparing plan tiers",
|
||||
spec: {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "lg",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: ["heading", "subtitle", "accordion"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Feature Comparison", level: "h1" },
|
||||
},
|
||||
subtitle: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Expand each section to see how plans compare.",
|
||||
variant: "muted",
|
||||
},
|
||||
},
|
||||
accordion: {
|
||||
type: "Accordion",
|
||||
props: {
|
||||
items: [
|
||||
{
|
||||
title: "Projects & Team",
|
||||
content:
|
||||
"Free: 3 projects, 1 member | Pro: Unlimited projects, 10 members | Enterprise: Unlimited everything",
|
||||
},
|
||||
{
|
||||
title: "Storage & Bandwidth",
|
||||
content:
|
||||
"Free: 1 GB storage, 10 GB/mo bandwidth | Pro: 50 GB storage, 500 GB/mo | Enterprise: Unlimited",
|
||||
},
|
||||
{
|
||||
title: "Support & SLA",
|
||||
content:
|
||||
"Free: Forum, no SLA | Pro: Email + Chat, 99.9% SLA | Enterprise: Dedicated Slack, 99.99% SLA",
|
||||
},
|
||||
],
|
||||
type: "single",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Advanced: Registration form with cross-field validation & $template
|
||||
// =========================================================================
|
||||
{
|
||||
name: "Registration Form",
|
||||
description:
|
||||
"Cross-field validation, $template preview, and validateForm action",
|
||||
spec: {
|
||||
root: "card",
|
||||
state: {
|
||||
form: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
accountType: "personal",
|
||||
company: "",
|
||||
},
|
||||
result: null,
|
||||
},
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Create Account",
|
||||
description: "Fill out the form below to register",
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["formStack"],
|
||||
},
|
||||
formStack: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: [
|
||||
"preview",
|
||||
"sep0",
|
||||
"nameInput",
|
||||
"emailInput",
|
||||
"passwordInput",
|
||||
"confirmInput",
|
||||
"sep1",
|
||||
"accountTypeRadio",
|
||||
"companyInput",
|
||||
"sep2",
|
||||
"actions",
|
||||
"statusText",
|
||||
],
|
||||
},
|
||||
|
||||
// $template live preview
|
||||
preview: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: {
|
||||
$template: "Welcome, ${/form/name}! Your email: ${/form/email}",
|
||||
},
|
||||
variant: "muted",
|
||||
},
|
||||
visible: { $state: "/form/name", neq: "" },
|
||||
children: [],
|
||||
},
|
||||
sep0: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
nameInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Full Name",
|
||||
name: "name",
|
||||
type: "text",
|
||||
placeholder: "Jane Doe",
|
||||
value: { $bindState: "/form/name" },
|
||||
checks: [
|
||||
{ type: "required", message: "Name is required" },
|
||||
{
|
||||
type: "minLength",
|
||||
args: { min: 2 },
|
||||
message: "Name must be at least 2 characters",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
emailInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
placeholder: "jane@example.com",
|
||||
value: { $bindState: "/form/email" },
|
||||
checks: [
|
||||
{ type: "required", message: "Email is required" },
|
||||
{ type: "email", message: "Enter a valid email address" },
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
passwordInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Password",
|
||||
name: "password",
|
||||
type: "password",
|
||||
placeholder: "At least 8 characters",
|
||||
value: { $bindState: "/form/password" },
|
||||
checks: [
|
||||
{ type: "required", message: "Password is required" },
|
||||
{
|
||||
type: "minLength",
|
||||
args: { min: 8 },
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
confirmInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Confirm Password",
|
||||
name: "confirmPassword",
|
||||
type: "password",
|
||||
placeholder: "Re-enter your password",
|
||||
value: { $bindState: "/form/confirmPassword" },
|
||||
checks: [
|
||||
{ type: "required", message: "Please confirm your password" },
|
||||
{
|
||||
type: "matches",
|
||||
args: { other: { $state: "/form/password" } },
|
||||
message: "Passwords must match",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
sep1: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
accountTypeRadio: {
|
||||
type: "Radio",
|
||||
props: {
|
||||
label: "Account Type",
|
||||
name: "accountType",
|
||||
options: ["personal", "business"],
|
||||
value: { $bindState: "/form/accountType" },
|
||||
checks: null,
|
||||
validateOn: null,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
companyInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Company Name",
|
||||
name: "company",
|
||||
type: "text",
|
||||
placeholder: "Acme Inc.",
|
||||
value: { $bindState: "/form/company" },
|
||||
checks: [
|
||||
{
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: "/form/accountType" } },
|
||||
message: "Company name is required for business accounts",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
visible: { $state: "/form/accountType", eq: "business" },
|
||||
children: [],
|
||||
},
|
||||
sep2: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
actions: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: "end",
|
||||
},
|
||||
children: ["submitBtn"],
|
||||
},
|
||||
submitBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Register", variant: "primary", disabled: null },
|
||||
on: {
|
||||
press: [
|
||||
{
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
// Validation result
|
||||
statusText: {
|
||||
type: "Alert",
|
||||
props: {
|
||||
title: "Validation Result",
|
||||
message: {
|
||||
$cond: { $state: "/result/valid", eq: true },
|
||||
$then: "All fields are valid -- ready to submit!",
|
||||
$else: "Please fix the errors above before submitting.",
|
||||
},
|
||||
type: {
|
||||
$cond: { $state: "/result/valid", eq: true },
|
||||
$then: "success",
|
||||
$else: "error",
|
||||
},
|
||||
},
|
||||
visible: { $state: "/result", neq: null },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Advanced: Cascading selects with watchers & $computed
|
||||
// =========================================================================
|
||||
{
|
||||
name: "Cascading Selects",
|
||||
description:
|
||||
"Watchers reset dependent fields, $computed derives display values",
|
||||
spec: {
|
||||
root: "card",
|
||||
state: {
|
||||
form: { country: "", city: "" },
|
||||
availableCities: [],
|
||||
},
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Shipping Address",
|
||||
description: "Select your country to load available cities",
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["formStack"],
|
||||
},
|
||||
formStack: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: [
|
||||
"countrySelect",
|
||||
"citySelect",
|
||||
"sep",
|
||||
"addressPreview",
|
||||
"templatePreview",
|
||||
],
|
||||
},
|
||||
|
||||
countrySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Country",
|
||||
name: "country",
|
||||
options: ["US", "Canada", "UK", "Germany", "Japan"],
|
||||
placeholder: "Choose a country",
|
||||
value: { $bindState: "/form/country" },
|
||||
checks: [{ type: "required", message: "Country is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
watch: {
|
||||
"/form/country": [
|
||||
{
|
||||
action: "setState",
|
||||
params: {
|
||||
statePath: "/availableCities",
|
||||
value: {
|
||||
$computed: "citiesForCountry",
|
||||
args: { country: { $state: "/form/country" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "setState",
|
||||
params: { statePath: "/form/city", value: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
citySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "City",
|
||||
name: "city",
|
||||
options: { $state: "/availableCities" },
|
||||
placeholder: "Select a city",
|
||||
value: { $bindState: "/form/city" },
|
||||
checks: [{ type: "required", message: "City is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
sep: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
// $computed formatted address
|
||||
addressPreview: {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: {
|
||||
$computed: "formatAddress",
|
||||
args: {
|
||||
city: { $state: "/form/city" },
|
||||
country: { $state: "/form/country" },
|
||||
},
|
||||
},
|
||||
level: "h3",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
// $template string interpolation
|
||||
templatePreview: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: {
|
||||
$template:
|
||||
"Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
|
||||
},
|
||||
variant: "muted",
|
||||
},
|
||||
visible: { $state: "/form/country", neq: "" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { z } from "zod";
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
...shadcnComponentDefinitions,
|
||||
},
|
||||
actions: {
|
||||
confetti: {
|
||||
params: z.object({}),
|
||||
description: "Fire confetti",
|
||||
},
|
||||
},
|
||||
functions: {
|
||||
formatAddress: {
|
||||
description:
|
||||
"Formats country and city into a single address string like 'City, Country'",
|
||||
},
|
||||
citiesForCountry: {
|
||||
description: "Returns an array of city names for the given country code",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
let confettiListener: (() => void) | null = null;
|
||||
|
||||
export function onConfetti(cb: () => void) {
|
||||
confettiListener = cb;
|
||||
return () => {
|
||||
confettiListener = null;
|
||||
};
|
||||
}
|
||||
|
||||
const cityData: Record<string, string[]> = {
|
||||
US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
|
||||
Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
|
||||
UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
|
||||
Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
|
||||
Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
|
||||
};
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
...shadcnComponents,
|
||||
},
|
||||
actions: {
|
||||
confetti: async () => {
|
||||
confettiListener?.();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const actionHandlers: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => void
|
||||
> = {
|
||||
confetti: () => confettiListener?.(),
|
||||
};
|
||||
|
||||
export const computedFunctions: Record<string, ComputedFunction> = {
|
||||
formatAddress: (args) => {
|
||||
const city = (args.city as string) ?? "";
|
||||
const country = (args.country as string) ?? "";
|
||||
if (!city && !country) return "No location selected";
|
||||
if (!city) return country;
|
||||
return `${city}, ${country}`;
|
||||
},
|
||||
citiesForCountry: (args) => {
|
||||
const country = (args.country as string) ?? "";
|
||||
return cityData[country] ?? [];
|
||||
},
|
||||
};
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "example-no-ai",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless no-ai-demo.json-render next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@json-render/shadcn": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-confetti-explosion": "^3.0.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../packages/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# example-react-native
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react-native@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1d755c1]
|
||||
- @json-render/core@0.9.0
|
||||
- @json-render/react-native@0.9.0
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-native",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Required: AI Gateway API key
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# Optional: override the default model (default: anthropic/claude-haiku-4.5)
|
||||
# AI_GATEWAY_MODEL=openai/gpt-4o-mini
|
||||
@@ -0,0 +1,17 @@
|
||||
# example-react-pdf
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react-pdf@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1d755c1]
|
||||
- @json-render/core@0.9.0
|
||||
- @json-render/react-pdf@0.9.0
|
||||
@@ -0,0 +1,35 @@
|
||||
import { streamText } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
import { buildUserPrompt, type Spec } from "@json-render/core";
|
||||
import { pdfCatalog } from "@/lib/catalog";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const SYSTEM_PROMPT = pdfCatalog.prompt();
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, startingSpec } = (await req.json()) as {
|
||||
prompt: string;
|
||||
startingSpec?: Spec | null;
|
||||
};
|
||||
|
||||
if (!prompt || typeof prompt !== "string") {
|
||||
return Response.json({ error: "prompt is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
currentSpec: startingSpec,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: gateway(process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL),
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderToBuffer } from "@json-render/react-pdf/render";
|
||||
import { examples } from "@/lib/examples";
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const name = searchParams.get("name") ?? "invoice";
|
||||
const download = searchParams.get("download") === "1";
|
||||
|
||||
const example = examples.find((e) => e.name === name);
|
||||
if (!example) {
|
||||
return new Response("Example not found", { status: 404 });
|
||||
}
|
||||
|
||||
return pdfResponse(example.spec, name, download);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { spec, download, filename } = (await req.json()) as {
|
||||
spec: Spec;
|
||||
download?: boolean;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
if (!spec || !spec.root || !spec.elements) {
|
||||
return new Response("Invalid spec", { status: 400 });
|
||||
}
|
||||
|
||||
return pdfResponse(spec, filename ?? "document", download ?? false);
|
||||
}
|
||||
|
||||
async function pdfResponse(spec: Spec, name: string, download: boolean) {
|
||||
const buffer = await renderToBuffer(spec);
|
||||
|
||||
const disposition = download
|
||||
? `attachment; filename="${name}.pdf"`
|
||||
: `inline; filename="${name}.pdf"`;
|
||||
|
||||
return new Response(buffer as unknown as ArrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": disposition,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "json-render React PDF Example",
|
||||
description:
|
||||
"Generate PDF documents from JSON specs with @json-render/react-pdf",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { examples } from "@/lib/examples";
|
||||
import { createSpecStreamCompiler } from "@json-render/core";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { FileText, Download, Loader2, ArrowRight, Square } from "lucide-react";
|
||||
|
||||
type Mode = "scratch" | "example";
|
||||
type MobileView = "json" | "preview";
|
||||
|
||||
interface Selection {
|
||||
mode: Mode;
|
||||
exampleName?: string;
|
||||
}
|
||||
|
||||
const PDF_REFRESH_INTERVAL_MS = 2000;
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
|
||||
>
|
||||
{copied ? "copied" : "copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderableSpec(spec: Spec | null): spec is Spec {
|
||||
if (!spec?.root || !spec.elements) return false;
|
||||
const root = spec.elements[spec.root];
|
||||
if (!root) return false;
|
||||
if (root.type !== "Document" || !root.children?.length) return false;
|
||||
const firstChild = spec.elements[root.children[0]!];
|
||||
return firstChild?.type === "Page";
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [selection, setSelection] = useState<Selection>({
|
||||
mode: "example",
|
||||
exampleName: examples[0]!.name,
|
||||
});
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generatedSpec, setGeneratedSpec] = useState<Spec | null>(null);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
||||
const [examplesSheetOpen, setExamplesSheetOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const pdfUrlRef = useRef<string | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const codeScrollRef = useRef<HTMLDivElement>(null);
|
||||
const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!generating) return;
|
||||
codeScrollRef.current?.scrollTo({
|
||||
top: codeScrollRef.current.scrollHeight,
|
||||
});
|
||||
mobileCodeScrollRef.current?.scrollTo({
|
||||
top: mobileCodeScrollRef.current.scrollHeight,
|
||||
});
|
||||
}, [generating, generatedSpec]);
|
||||
|
||||
const currentExample =
|
||||
selection.mode === "example"
|
||||
? examples.find((e) => e.name === selection.exampleName)
|
||||
: null;
|
||||
|
||||
const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
|
||||
|
||||
const examplePdfUrl =
|
||||
selection.mode === "example" && !generatedSpec
|
||||
? `/api/pdf?name=${selection.exampleName}`
|
||||
: null;
|
||||
|
||||
const displayPdfUrl = pdfUrl ?? examplePdfUrl;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selection.mode, selection.exampleName]);
|
||||
|
||||
const fetchPdfBlob = useCallback(async (spec: Spec, signal?: AbortSignal) => {
|
||||
const res = await fetch("/api/pdf", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ spec }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to generate PDF");
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const prev = pdfUrlRef.current;
|
||||
pdfUrlRef.current = url;
|
||||
setPdfUrl(url);
|
||||
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
}, []);
|
||||
|
||||
// Progressive PDF refresh during generation
|
||||
const lastRefreshSpec = useRef<string>("");
|
||||
const generatedSpecRef = useRef<Spec | null>(null);
|
||||
generatedSpecRef.current = generatedSpec;
|
||||
|
||||
useEffect(() => {
|
||||
if (!generating) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const spec = generatedSpecRef.current;
|
||||
if (!spec) return;
|
||||
|
||||
const specKey = JSON.stringify(spec);
|
||||
if (specKey === lastRefreshSpec.current) return;
|
||||
if (!isRenderableSpec(spec)) return;
|
||||
|
||||
lastRefreshSpec.current = specKey;
|
||||
setRefreshing(true);
|
||||
fetchPdfBlob(spec)
|
||||
.catch(() => {})
|
||||
.finally(() => setRefreshing(false));
|
||||
}, PDF_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [generating, fetchPdfBlob]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
lastRefreshSpec.current = "";
|
||||
|
||||
try {
|
||||
const startingSpec =
|
||||
selection.mode === "example" && currentExample
|
||||
? currentExample.spec
|
||||
: null;
|
||||
|
||||
const res = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt: prompt.trim(), startingSpec }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error("Generation failed");
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const compiler = createSpecStreamCompiler<Spec>(
|
||||
startingSpec ? { ...startingSpec } : {},
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const { result, newPatches } = compiler.push(chunk);
|
||||
if (newPatches.length > 0) setGeneratedSpec(result);
|
||||
}
|
||||
|
||||
const finalSpec = compiler.getResult();
|
||||
setGeneratedSpec(finalSpec);
|
||||
setGenerating(false);
|
||||
|
||||
await fetchPdfBlob(finalSpec);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : "Something went wrong");
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [prompt, selection, currentExample, fetchPdfBlob]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setGenerating(false);
|
||||
|
||||
if (isRenderableSpec(generatedSpec)) {
|
||||
fetchPdfBlob(generatedSpec).catch(() => {});
|
||||
}
|
||||
}, [generatedSpec, fetchPdfBlob]);
|
||||
|
||||
const select = (next: Selection) => {
|
||||
abortRef.current?.abort();
|
||||
setSelection(next);
|
||||
setGeneratedSpec(null);
|
||||
setPdfUrl(null);
|
||||
setError(null);
|
||||
setPrompt("");
|
||||
setGenerating(false);
|
||||
setExamplesSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!activeSpec) return;
|
||||
if (generatedSpec) {
|
||||
const res = await fetch("/api/pdf", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ spec: generatedSpec, download: true }),
|
||||
});
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "document.pdf";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else if (selection.mode === "example") {
|
||||
window.open(
|
||||
`/api/pdf?name=${selection.exampleName}&download=1`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
}
|
||||
},
|
||||
[handleGenerate],
|
||||
);
|
||||
|
||||
const jsonCode = activeSpec
|
||||
? JSON.stringify(activeSpec, null, 2)
|
||||
: "// select an example or generate a PDF";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane: Chat / Examples
|
||||
// ---------------------------------------------------------------------------
|
||||
const chatPane = (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
json-render / react-pdf
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||
start
|
||||
</p>
|
||||
<button
|
||||
onClick={() => select({ mode: "scratch" })}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||
selection.mode === "scratch"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">From scratch</span>
|
||||
</button>
|
||||
|
||||
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||
examples
|
||||
</p>
|
||||
{examples.map((ex) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => select({ mode: "example", exampleName: ex.name })}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||
selection.mode === "example" &&
|
||||
selection.exampleName === ex.name
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{ex.label}</span>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
|
||||
{ex.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div
|
||||
className="border-t border-border p-3 cursor-text"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
selection.mode === "scratch"
|
||||
? "Describe the PDF you want..."
|
||||
: `Modify the ${currentExample?.label ?? "example"}...`
|
||||
}
|
||||
className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{selection.mode === "example" && currentExample
|
||||
? currentExample.label
|
||||
: "scratch"}
|
||||
</span>
|
||||
{generating ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<Square className="h-3 w-3" fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Generate"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane: JSON Spec
|
||||
// ---------------------------------------------------------------------------
|
||||
const codePane = (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-foreground">json</span>
|
||||
{generating && (
|
||||
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{activeSpec && <CopyButton text={jsonCode} />}
|
||||
</div>
|
||||
<div ref={codeScrollRef} className="flex-1 overflow-auto">
|
||||
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
|
||||
{jsonCode}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane: PDF Preview
|
||||
// ---------------------------------------------------------------------------
|
||||
const previewPane = (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-foreground">preview</span>
|
||||
{(generating || refreshing) && (
|
||||
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{activeSpec && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 relative bg-neutral-600">
|
||||
{displayPdfUrl ? (
|
||||
<iframe
|
||||
key={displayPdfUrl}
|
||||
src={displayPdfUrl}
|
||||
className="h-full w-full border-none"
|
||||
title="PDF preview"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<FileText className="h-10 w-10" />
|
||||
<p className="text-sm">
|
||||
{selection.mode === "scratch"
|
||||
? "Enter a prompt to generate a PDF"
|
||||
: "Select an example to preview"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<div className="h-dvh flex flex-col">
|
||||
{/* Desktop: 3-pane resizable layout */}
|
||||
<div className="hidden lg:flex flex-1 min-h-0">
|
||||
<ResizablePanelGroup className="flex-1">
|
||||
<ResizablePanel defaultSize={25} minSize={15}>
|
||||
{chatPane}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={35} minSize={20}>
|
||||
{codePane}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
{previewPane}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Mobile: toolbar + content + prompt */}
|
||||
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => setExamplesSheetOpen(true)}
|
||||
className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
|
||||
>
|
||||
{selection.mode === "example" && currentExample
|
||||
? currentExample.label
|
||||
: "scratch"}
|
||||
</button>
|
||||
{(["json", "preview"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setMobileView(tab)}
|
||||
className={cn(
|
||||
"text-xs font-mono transition-colors shrink-0",
|
||||
mobileView === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
{(generating || refreshing) && (
|
||||
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin shrink-0" />
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{activeSpec && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={mobileCodeScrollRef} className="flex-1 min-h-0 overflow-auto">
|
||||
{mobileView === "json" ? (
|
||||
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
|
||||
{jsonCode}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="h-full relative bg-neutral-600">
|
||||
{displayPdfUrl ? (
|
||||
<iframe
|
||||
key={displayPdfUrl}
|
||||
src={displayPdfUrl}
|
||||
className="h-full w-full border-none"
|
||||
title="PDF preview"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<FileText className="h-10 w-10" />
|
||||
<p className="text-sm">Enter a prompt to generate a PDF</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="border-t border-border p-3 shrink-0 cursor-text"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
mobileInputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={mobileInputRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
selection.mode === "scratch"
|
||||
? "Describe the PDF you want..."
|
||||
: `Modify the ${currentExample?.label ?? "example"}...`
|
||||
}
|
||||
className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{selection.mode === "example" && currentExample
|
||||
? currentExample.label
|
||||
: "scratch"}
|
||||
</span>
|
||||
{generating ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<Square className="h-3 w-3" fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Generate"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
|
||||
<SheetContent side="left" className="w-80 p-0">
|
||||
<SheetTitle className="sr-only">Examples</SheetTitle>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1">
|
||||
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||
start
|
||||
</p>
|
||||
<button
|
||||
onClick={() => select({ mode: "scratch" })}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||
selection.mode === "scratch"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
From scratch
|
||||
</button>
|
||||
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||
examples
|
||||
</p>
|
||||
{examples.map((ex) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() =>
|
||||
select({ mode: "example", exampleName: ex.name })
|
||||
}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||
selection.mode === "example" &&
|
||||
selection.exampleName === ex.name
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{ex.label}</span>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
|
||||
{ex.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user