Compare commits

...
Author SHA1 Message Date
Chris Tate d39645fbcf v0.7.0 changeset 2026-02-17 01:32:45 -06:00
Chris Tate 320c935bfb shadcn (#115)
* shadcn

* fix build error

* fix readme

* fix CI error

* improvements

* fixes

* stronger types

* fix interleave

* fixes

* on arg

* minor fixes

* fix lock
2026-02-17 01:28:04 -06:00
github-actions[bot] e7103ce519 chore: version packages (#113)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-15 09:06:46 -06:00
Chris Tate 43ad534482 v0.6.1 changeset (#112) 2026-02-15 09:04:39 -06:00
Chris Tate ea97aff3e0 fix max update depth on homepage demo (#111)
When form inputs lack `$bindState` bindings (like in the homepage contact form simulation), `useFieldValidation` was called with `bindings?.value ?? ""` as the path. All three inputs registered at the same `""` path but with different validation configs. Each `registerField` call overwrote the previous one, triggering a re-render where the other inputs would see a mismatched config and re-register -- creating an infinite loop.

- Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at path `""` with different `checks` configs, causing them to overwrite each other endlessly
- Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update
2026-02-15 08:56:08 -06:00
73 changed files with 5273 additions and 475 deletions
+1
View File
@@ -6,6 +6,7 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
"@json-render/codegen"
+47
View File
@@ -0,0 +1,47 @@
---
"@json-render/core": minor
"@json-render/react": minor
"@json-render/shadcn": minor
---
New `@json-render/shadcn` package, event handles, built-in actions, and stream improvements.
### New: `@json-render/shadcn` Package
Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 30+ components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
- `shadcnComponentDefinitions` — Zod-based catalog definitions for all components (server-safe, no React dependency via `@json-render/shadcn/catalog`)
- `shadcnComponents` — React implementations for all components
- 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
- Input: Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup
### 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
### 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.
### 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.
-2
View File
@@ -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
+5 -1
View File
@@ -25,12 +25,16 @@ 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>`
## 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)
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
- `AGENTS.md` (if workflow or conventions change)
<!-- opensrc:start -->
+36
View File
@@ -6,6 +6,8 @@ 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
@@ -20,6 +22,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** - 30+ pre-built shadcn/ui components ready to use
## Quick Start
@@ -105,6 +108,7 @@ function Dashboard({ spec }) {
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/shadcn` | 30+ 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 |
@@ -138,6 +142,38 @@ const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### shadcn/ui (Web)
```tsx
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
// Pick components from the 30+ 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
+12 -9
View File
@@ -781,9 +781,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
@@ -822,9 +823,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
@@ -864,9 +866,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
+3 -1
View File
@@ -22,4 +22,6 @@ const nextConfig = {
const withMDX = createMDX({});
export default withMDX(nextConfig);
/** @type {import('next').NextConfig} */
const config = withMDX(nextConfig);
export default config;
+13 -101
View File
@@ -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(
+13 -111
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -40,7 +40,7 @@
"typescript": "5.9.2",
"vitest": "^4.0.17"
},
"packageManager": "pnpm@9.0.0",
"packageManager": "pnpm@10.29.3",
"engines": {
"node": ">=18"
},
+13
View File
@@ -1,5 +1,18 @@
# @json-render/codegen
## 0.6.1
### Patch Changes
- @json-render/core@0.6.1
## 0.6.1
### Patch Changes
- Updated dependencies [ea97aff]
- @json-render/core@0.6.1
## 0.6.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.6.0",
"version": "0.6.1",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+8
View File
@@ -1,5 +1,13 @@
# @json-render/core
## 0.6.1
## 0.6.1
### Patch Changes
- ea97aff: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update.
## 0.6.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.6.0",
"version": "0.6.1",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
+3
View File
@@ -47,6 +47,8 @@ export interface ActionBinding {
onSuccess?: ActionOnSuccess;
/** Handler after failed execution */
onError?: ActionOnError;
/** Whether to prevent default browser behavior (e.g. navigation on links) */
preventDefault?: boolean;
}
/**
@@ -91,6 +93,7 @@ export const ActionBindingSchema = z.object({
confirm: ActionConfirmSchema.optional(),
onSuccess: ActionOnSuccessSchema.optional(),
onError: ActionOnErrorSchema.optional(),
preventDefault: z.boolean().optional(),
});
/**
+1
View File
@@ -143,6 +143,7 @@ export type {
Schema,
PromptTemplate,
SchemaOptions,
BuiltInAction,
} from "./schema";
export { defineSchema } from "./schema";
+39 -3
View File
@@ -66,6 +66,8 @@ export interface Schema<TDef extends SchemaDefinition = SchemaDefinition> {
readonly promptTemplate?: PromptTemplate;
/** Default rules baked into the schema (injected before customRules) */
readonly defaultRules?: string[];
/** Built-in actions always available at runtime (injected into prompts automatically) */
readonly builtInActions?: BuiltInAction[];
/** Create a catalog from this schema */
createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
catalog: TCatalog,
@@ -140,6 +142,18 @@ export type PromptTemplate<TCatalog = unknown> = (
context: PromptContext<TCatalog>,
) => string;
/**
* A built-in action that is always available regardless of catalog configuration.
* These are handled by the runtime (e.g. ActionProvider) and injected into prompts
* automatically so the LLM knows about them.
*/
export interface BuiltInAction {
/** Action name (e.g. "setState") */
name: string;
/** Human-readable description for the LLM */
description: string;
}
/**
* Schema options
*/
@@ -148,6 +162,13 @@ export interface SchemaOptions<TCatalog = unknown> {
promptTemplate?: PromptTemplate<TCatalog>;
/** Default rules baked into the schema (injected before customRules in prompts) */
defaultRules?: string[];
/**
* Built-in actions that are always available regardless of catalog configuration.
* These are injected into prompts automatically so the LLM knows about them,
* but they don't require handlers in defineRegistry because the runtime
* (e.g. ActionProvider) handles them directly.
*/
builtInActions?: BuiltInAction[];
}
/**
@@ -347,6 +368,7 @@ export function defineSchema<TDef extends SchemaDefinition>(
definition,
promptTemplate: options?.promptTemplate,
defaultRules: options?.defaultRules,
builtInActions: options?.builtInActions,
createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
catalog: TCatalog,
): Catalog<TDef, TCatalog> {
@@ -749,12 +771,26 @@ Note: state patches appear right after the elements that use them, so the UI fil
| Record<string, { params?: z.ZodType; description?: string }>
| undefined;
if (actions && catalog.actionNames.length > 0) {
const builtInActions = catalog.schema.builtInActions ?? [];
const hasCustomActions = actions && catalog.actionNames.length > 0;
const hasBuiltInActions = builtInActions.length > 0;
if (hasCustomActions || hasBuiltInActions) {
lines.push("AVAILABLE ACTIONS:");
lines.push("");
for (const [name, def] of Object.entries(actions)) {
lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
// Built-in actions (handled by runtime, always available)
for (const action of builtInActions) {
lines.push(`- ${action.name}: ${action.description} [built-in]`);
}
// Custom actions (declared in catalog, require handlers)
if (hasCustomActions) {
for (const [name, def] of Object.entries(actions)) {
lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
}
}
lines.push("");
}
+98
View File
@@ -1022,6 +1022,104 @@ describe("createJsonRenderTransform", () => {
const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
expect(specChunks.length).toBe(1);
});
// ===========================================================================
// Text block splitting around spec data
// ===========================================================================
it("splits text blocks around spec data (text-start/text-end pairs)", async () => {
const input = [
"Some text\n",
'{"op":"add","path":"/root","value":"r"}\n',
"More text\n",
].join("");
const chunks = await transformText(input);
const textStarts = chunks.filter((c) => c.type === "text-start");
const textEnds = chunks.filter((c) => c.type === "text-end");
// There should be two text blocks: one before the patch and one after
expect(textStarts.length).toBe(2);
expect(textEnds.length).toBe(2);
// Spec data should appear between the two text blocks
const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
expect(specChunks.length).toBe(1);
// Find the indices of the first text-end and the spec chunk
const firstTextEndIdx = chunks.findIndex((c) => c.type === "text-end");
const specIdx = chunks.findIndex((c) => c.type === SPEC_DATA_PART_TYPE);
const secondTextStartIdx = chunks.findIndex(
(c, i) => i > specIdx && c.type === "text-start",
);
expect(firstTextEndIdx).toBeLessThan(specIdx);
expect(specIdx).toBeLessThan(secondTextStartIdx);
});
it("flush closes an open text block when stream ends without text-end", async () => {
const transform = createJsonRenderTransform();
const writer = transform.writable.getWriter();
const reader = transform.readable.getReader();
const chunks: StreamChunk[] = [];
const readAll = (async () => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
})();
// Write text-start + text-delta, then close WITHOUT text-end
await writer.write({ type: "text-start", id: "t1" });
await writer.write({
type: "text-delta",
id: "t1",
delta: "Hello world\n",
});
await writer.close();
await readAll;
// The transform's flush should have emitted a text-end to close the block
const textEnds = chunks.filter((c) => c.type === "text-end");
expect(textEnds.length).toBe(1);
// Text content should still be present
const textChunks = chunks.filter((c) => c.type === "text-delta");
const text = textChunks.map((c) => (c as { delta: string }).delta).join("");
expect(text).toContain("Hello world");
});
it("consecutive patches do not produce empty text blocks", async () => {
const input = [
'{"op":"add","path":"/root","value":"r"}\n',
'{"op":"add","path":"/elements/r","value":{"type":"Card","props":{},"children":[]}}\n',
].join("");
const chunks = await transformText(input);
const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
expect(specChunks.length).toBe(2);
// There should be no text-start/text-end pairs between the two spec chunks
// (the initial text-start from the upstream is forwarded, but no new empty ones)
const textDeltas = chunks.filter((c) => c.type === "text-delta");
const textContent = textDeltas
.map((c) => (c as { delta: string }).delta)
.join("")
.trim();
// No meaningful text content between the patches
expect(textContent).toBe("");
// Count text blocks: there should be at most 1 (the initial upstream one),
// not extra empty ones inserted between patches
const textStarts = chunks.filter((c) => c.type === "text-start");
const textEnds = chunks.filter((c) => c.type === "text-end");
expect(textStarts.length).toBeLessThanOrEqual(1);
expect(textEnds.length).toBeLessThanOrEqual(1);
});
});
// =============================================================================
+56 -38
View File
@@ -1010,11 +1010,48 @@ export function createJsonRenderTransform(): TransformStream<
let buffering = false;
// Whether we are inside a ```spec fence
let inSpecFence = false;
// Whether we are currently inside a text block (between text-start/text-end).
// Used to split text blocks around spec data so the AI SDK creates separate
// text parts, preserving interleaving of prose and UI in message.parts.
let inTextBlock = false;
let textIdCounter = 0;
/** Close the current text block if one is open. */
function closeTextBlock(
controller: TransformStreamDefaultController<StreamChunk>,
) {
if (inTextBlock) {
controller.enqueue({ type: "text-end", id: currentTextId });
inTextBlock = false;
}
}
/** Ensure a text block is open, starting a new one if needed. */
function ensureTextBlock(
controller: TransformStreamDefaultController<StreamChunk>,
) {
if (!inTextBlock) {
textIdCounter++;
currentTextId = String(textIdCounter);
controller.enqueue({ type: "text-start", id: currentTextId });
inTextBlock = true;
}
}
/** Emit a text-delta, opening a text block first if necessary. */
function emitTextDelta(
delta: string,
controller: TransformStreamDefaultController<StreamChunk>,
) {
ensureTextBlock(controller);
controller.enqueue({ type: "text-delta", id: currentTextId, delta });
}
function emitPatch(
patch: SpecStreamLine,
controller: TransformStreamDefaultController<StreamChunk>,
) {
closeTextBlock(controller);
controller.enqueue({
type: SPEC_DATA_PART_TYPE,
data: { type: "patch", patch },
@@ -1046,19 +1083,11 @@ export function createJsonRenderTransform(): TransformStream<
emitPatch(patch, controller);
} else {
// Was buffered but isn't JSONL — flush as text
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: lineBuffer,
});
emitTextDelta(lineBuffer, controller);
}
} else {
// Whitespace-only buffer — forward as-is (preserves blank lines)
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: lineBuffer,
});
emitTextDelta(lineBuffer, controller);
}
lineBuffer = "";
buffering = false;
@@ -1092,11 +1121,7 @@ export function createJsonRenderTransform(): TransformStream<
// --- Outside fence: heuristic mode ---
if (!trimmed) {
// Empty line — forward for markdown paragraph breaks
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: "\n",
});
emitTextDelta("\n", controller);
return;
}
@@ -1104,11 +1129,7 @@ export function createJsonRenderTransform(): TransformStream<
if (patch) {
emitPatch(patch, controller);
} else {
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: line + "\n",
});
emitTextDelta(line + "\n", controller);
}
}
@@ -1116,14 +1137,19 @@ export function createJsonRenderTransform(): TransformStream<
transform(chunk, controller) {
switch (chunk.type) {
case "text-start": {
currentTextId = (chunk as { id: string }).id;
const id = (chunk as { id: string }).id;
const idNum = parseInt(id, 10);
if (!isNaN(idNum) && idNum >= textIdCounter) {
textIdCounter = idNum;
}
currentTextId = id;
inTextBlock = true;
controller.enqueue(chunk);
break;
}
case "text-delta": {
const delta = chunk as { id: string; delta: string };
currentTextId = delta.id;
const text = delta.delta;
for (let i = 0; i < text.length; i++) {
@@ -1138,11 +1164,7 @@ export function createJsonRenderTransform(): TransformStream<
} else {
// Outside fence, emit newline; inside fence, swallow it
if (!inSpecFence) {
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: "\n",
});
emitTextDelta("\n", controller);
}
}
} else if (lineBuffer.length === 0 && !buffering) {
@@ -1152,20 +1174,12 @@ export function createJsonRenderTransform(): TransformStream<
buffering = true;
lineBuffer += ch;
} else {
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: ch,
});
emitTextDelta(ch, controller);
}
} else if (buffering) {
lineBuffer += ch;
} else {
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: ch,
});
emitTextDelta(ch, controller);
}
}
break;
@@ -1173,7 +1187,10 @@ export function createJsonRenderTransform(): TransformStream<
case "text-end": {
flushBuffer(controller);
controller.enqueue(chunk);
if (inTextBlock) {
controller.enqueue({ type: "text-end", id: currentTextId });
inTextBlock = false;
}
break;
}
@@ -1186,6 +1203,7 @@ export function createJsonRenderTransform(): TransformStream<
flush(controller) {
flushBuffer(controller);
closeTextBlock(controller);
},
});
}
+13
View File
@@ -1,5 +1,18 @@
# @json-render/react-native
## 0.6.1
### Patch Changes
- @json-render/core@0.6.1
## 0.6.1
### Patch Changes
- Updated dependencies [ea97aff]
- @json-render/core@0.6.1
## 0.6.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.6.0",
"version": "0.6.1",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
+15
View File
@@ -1,5 +1,20 @@
# @json-render/react
## 0.6.1
### Patch Changes
- 43ad534: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update.
- @json-render/core@0.6.1
## 0.6.1
### Patch Changes
- ea97aff: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update.
- Updated dependencies [ea97aff]
- @json-render/core@0.6.1
## 0.6.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.6.0",
"version": "0.6.1",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
+60 -12
View File
@@ -25,6 +25,53 @@ export type SetState = (
// Component Types
// =============================================================================
/**
* Handle returned by the `on()` function for a specific event.
* Provides metadata about the event binding and a method to fire it.
*
* @example
* ```ts
* const press = on("press");
* if (press.shouldPreventDefault) e.preventDefault();
* press.emit();
* ```
*/
export interface EventHandle {
/** Fire the event (resolve action bindings) */
emit: () => void;
/** Whether any binding requested preventDefault */
shouldPreventDefault: boolean;
/** Whether any handler is bound to this event */
bound: boolean;
}
/**
* Catalog-agnostic base type for component render function arguments.
* Use this when building reusable component libraries (e.g. `@json-render/shadcn`)
* that are not tied to a specific catalog.
*
* @example
* ```ts
* const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
* <div>{props.title}{children}</div>
* );
* ```
*/
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
children?: ReactNode;
/** Simple event emitter (shorthand). Fires the event and returns void. */
emit: (event: string) => void;
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
on: (event: string) => EventHandle;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
loading?: boolean;
}
/**
* Context passed to component render functions
* @example
@@ -35,18 +82,7 @@ export type SetState = (
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> {
props: InferComponentProps<C, K>;
children?: ReactNode;
/** Emit a named event. The renderer resolves the event to an action binding from the element's `on` field. */
emit: (event: string) => void;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
loading?: boolean;
}
> extends BaseComponentProps<InferComponentProps<C, K>> {}
/**
* Component render function type for React
@@ -104,3 +140,15 @@ export type ActionFn<
export type Actions<C extends Catalog> = {
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
};
/**
* True when the catalog declares at least one action, false otherwise.
* Used by defineRegistry to conditionally require the `actions` field.
*/
export type CatalogHasActions<C extends Catalog> = [
InferCatalogActions<C>,
] extends [never]
? false
: [keyof InferCatalogActions<C>] extends [never]
? false
: true;
+8 -2
View File
@@ -6,6 +6,7 @@ import React, {
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
@@ -137,6 +138,11 @@ export function ActionProvider({
children,
}: ActionProviderProps) {
const { state, get, set } = useStateStore();
// Keep a ref to the latest state so `execute` doesn't change on every
// state update — preventing the entire action context from churning.
const stateRef = useRef(state);
stateRef.current = state;
const [handlers, setHandlers] =
useState<Record<string, ActionHandler>>(initialHandlers);
const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
@@ -152,7 +158,7 @@ export function ActionProvider({
const execute = useCallback(
async (binding: ActionBinding) => {
const resolved = resolveAction(binding, state);
const resolved = resolveAction(binding, stateRef.current);
// Built-in: setState updates the StateProvider state directly
if (resolved.action === "setState" && resolved.params) {
@@ -299,7 +305,7 @@ export function ActionProvider({
});
}
},
[state, handlers, get, set, navigate],
[handlers, get, set, navigate],
);
const confirm = useCallback(() => {
+11 -4
View File
@@ -6,6 +6,7 @@ import React, {
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
@@ -130,6 +131,11 @@ export function ValidationProvider({
children,
}: ValidationProviderProps) {
const { state } = useStateStore();
// Keep a ref to the latest state so `validate` doesn't change on every
// state update — preventing the entire validation context from churning.
const stateRef = useRef(state);
stateRef.current = state;
const [fieldStates, setFieldStates] = useState<
Record<string, FieldValidationState>
>({});
@@ -155,8 +161,9 @@ export function ValidationProvider({
const validate = useCallback(
(path: string, config: ValidationConfig): ValidationResult => {
// Walk the nested state object using JSON Pointer segments
const currentState = stateRef.current;
const segments = path.split("/").filter(Boolean);
let value: unknown = state;
let value: unknown = currentState;
for (const seg of segments) {
if (value != null && typeof value === "object") {
value = (value as Record<string, unknown>)[seg];
@@ -167,7 +174,7 @@ export function ValidationProvider({
}
const result = runValidation(config, {
value,
stateModel: state,
stateModel: currentState,
customFunctions,
});
@@ -182,7 +189,7 @@ export function ValidationProvider({
return result;
},
[state, customFunctions],
[customFunctions],
);
const touch = useCallback((path: string) => {
@@ -280,7 +287,7 @@ export function useFieldValidation(
// Register field on mount
React.useEffect(() => {
if (config) {
if (path && config) {
registerField(path, config);
}
}, [path, config, registerField]);
+10 -3
View File
@@ -250,6 +250,13 @@ export function useUIStream({
const [rawLines, setRawLines] = useState<string[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
// Keep refs to callbacks so `send` doesn't recreate when consumers
// pass inline arrow functions.
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const clear = useCallback(() => {
setSpec(null);
setError(null);
@@ -350,19 +357,19 @@ export function useUIStream({
}
}
onComplete?.(currentSpec);
onCompleteRef.current?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
onError?.(error);
onErrorRef.current?.(error);
} finally {
setIsStreaming(false);
}
},
[api, onComplete, onError],
[api],
);
// Cleanup on unmount
+2
View File
@@ -58,6 +58,8 @@ export type { Spec } from "@json-render/core";
// Catalog-aware types for React
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
+43 -13
View File
@@ -29,6 +29,8 @@ import type {
ActionFn,
SetState,
StateModel,
CatalogHasActions,
EventHandle,
} from "./catalog-types";
import { useIsVisible, useVisibility } from "./contexts/visibility";
import { useActions } from "./contexts/actions";
@@ -50,6 +52,8 @@ export interface ComponentRenderProps<P = Record<string, unknown>> {
children?: ReactNode;
/** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */
emit: (event: string) => void;
/** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */
on: (event: string) => EventHandle;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
@@ -199,6 +203,24 @@ const ElementRenderer = React.memo(function ElementRenderer({
[onBindings, execute, fullCtx],
);
// Create on() function that returns an EventHandle with metadata for a specific event.
const on = useCallback(
(eventName: string): EventHandle => {
const binding = onBindings?.[eventName];
if (!binding) {
return { emit: () => {}, shouldPreventDefault: false, bound: false };
}
const actionBindings = Array.isArray(binding) ? binding : [binding];
const shouldPreventDefault = actionBindings.some((b) => b.preventDefault);
return {
emit: () => emit(eventName),
shouldPreventDefault,
bound: true,
};
},
[onBindings, emit],
);
// Don't render if not visible
if (!isVisible) {
return null;
@@ -262,6 +284,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
<Component
element={resolvedElement}
emit={emit}
on={on}
bindings={elementBindings}
loading={loading}
>
@@ -466,12 +489,26 @@ export interface DefineRegistryResult {
) => Promise<void>;
}
/**
* Options for defineRegistry.
*
* When the catalog declares actions, the `actions` field is required.
* When the catalog has no actions (or `actions: {}`), the field is optional.
*/
type DefineRegistryOptions<C extends Catalog> = {
components?: Components<C>;
} & (CatalogHasActions<C> extends true
? { actions: Actions<C> }
: { actions?: Actions<C> });
/**
* Create a registry from a catalog with components and/or actions.
*
* When the catalog declares actions, the `actions` field is required.
*
* @example
* ```tsx
* // Components only
* // Components only (catalog has no actions)
* const { registry } = defineRegistry(catalog, {
* components: {
* Card: ({ props, children }) => (
@@ -480,14 +517,7 @@ export interface DefineRegistryResult {
* },
* });
*
* // Actions only
* const { handlers, executeAction } = defineRegistry(catalog, {
* actions: {
* viewCustomers: async (params, setState) => { ... },
* },
* });
*
* // Both
* // Both (catalog declares actions)
* const { registry, handlers, executeAction } = defineRegistry(catalog, {
* components: { ... },
* actions: { ... },
@@ -496,10 +526,7 @@ export interface DefineRegistryResult {
*/
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: {
components?: Components<C>;
actions?: Actions<C>;
},
options: DefineRegistryOptions<C>,
): DefineRegistryResult {
// Build component registry
const registry: ComponentRegistry = {};
@@ -509,6 +536,7 @@ export function defineRegistry<C extends Catalog>(
element,
children,
emit,
on,
bindings,
loading,
}: ComponentRenderProps) => {
@@ -516,6 +544,7 @@ export function defineRegistry<C extends Catalog>(
props: element.props,
children,
emit,
on,
bindings,
loading,
});
@@ -572,6 +601,7 @@ type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: React.ReactNode;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}) => React.ReactNode;
+17
View File
@@ -51,6 +51,23 @@ export const schema = defineSchema(
}),
}),
{
builtInActions: [
{
name: "setState",
description:
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
},
{
name: "pushState",
description:
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
},
{
name: "removeState",
description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
},
],
defaultRules: [
// Element integrity
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
+13
View File
@@ -1,5 +1,18 @@
# @json-render/remotion
## 0.6.1
### Patch Changes
- @json-render/core@0.6.1
## 0.6.1
### Patch Changes
- Updated dependencies [ea97aff]
- @json-render/core@0.6.1
## 0.6.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.6.0",
"version": "0.6.1",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
+198
View File
@@ -0,0 +1,198 @@
# @json-render/shadcn
Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. Drop-in catalog definitions and React implementations for 30+ components built on Radix UI + Tailwind CSS.
## Installation
```bash
npm install @json-render/shadcn @json-render/core @json-render/react zod
```
## Quick Start
### 1. Create a Catalog
Import standard definitions from `@json-render/shadcn/catalog` and pass them to `defineCatalog`:
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
const catalog = defineCatalog(schema, {
components: {
// Pick the components you need
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
```
> **Note:** State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled automatically by `ActionProvider`. You don't need to declare them in your catalog.
### 2. Create a Registry
Import standard implementations from `@json-render/shadcn` and pass them to `defineRegistry`:
```typescript
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
### 3. Render
```tsx
import { Renderer } from "@json-render/react";
function App({ spec }) {
return <Renderer spec={spec} registry={registry} />;
}
```
## Extending with Custom Components
Pick standard components as a base and add your own alongside them:
```typescript
import { z } from "zod";
// Catalog
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: {},
});
// Registry
const { registry } = defineRegistry(catalog, {
components: {
// Standard
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Button: shadcnComponents.Button,
// Custom
Metric: ({ props }) => (
<div>
<span>{props.label}</span>
<span>{props.value}</span>
</div>
),
},
});
```
## Standard Components
### Layout
| Component | Description |
|-----------|-------------|
| `Card` | Container card with optional title and description |
| `Stack` | Flex container (horizontal/vertical) with gap, alignment, justify |
| `Grid` | Grid layout (1-6 columns) |
| `Separator` | Visual separator line |
### Navigation
| Component | Description |
|-----------|-------------|
| `Tabs` | Tabbed navigation |
| `Accordion` | Collapsible accordion sections |
| `Collapsible` | Single collapsible section with trigger |
| `Pagination` | Page navigation |
### Overlay
| Component | Description |
|-----------|-------------|
| `Dialog` | Modal dialog |
| `Drawer` | Bottom drawer |
| `Tooltip` | Hover tooltip |
| `Popover` | Click-triggered popover |
| `DropdownMenu` | Dropdown menu with selectable items |
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | Heading text (h1-h4) |
| `Text` | Paragraph text with variants (body, caption, muted, lead, code) |
| `Image` | Placeholder image |
| `Avatar` | User avatar with fallback initials |
| `Badge` | Status badge |
| `Alert` | Alert banner |
| `Carousel` | Horizontally scrollable carousel |
| `Table` | Data table with columns and rows |
### Feedback
| Component | Description |
|-----------|-------------|
| `Progress` | Progress bar |
| `Skeleton` | Loading placeholder |
| `Spinner` | Loading spinner |
### Input
| Component | Description |
|-----------|-------------|
| `Button` | Clickable button with variants |
| `Link` | Anchor link |
| `Input` | Text input with label and validation |
| `Textarea` | Multi-line text input |
| `Select` | Dropdown select |
| `Checkbox` | Checkbox input |
| `Radio` | Radio button group |
| `Switch` | Toggle switch |
| `Slider` | Range slider |
| `Toggle` | Toggle button |
| `ToggleGroup` | Group of toggle buttons |
| `ButtonGroup` | Group of buttons with selected state |
## Built-in Actions
State actions (`setState`, `pushState`, `removeState`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog.
| Action | Description |
|--------|-------------|
| `setState` | Set a value at a state path |
| `pushState` | Push a value onto an array in state |
| `removeState` | Remove an item from an array in state |
## Exports
| Entry Point | Exports |
|-------------|---------|
| `@json-render/shadcn` | `shadcnComponents` |
| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` |
The `/catalog` entry point contains only Zod schemas (no React dependency), so it can be used in server-side code for prompt generation.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "src",
"utils": "src/lib/utils",
"ui": "src/ui",
"lib": "src/lib",
"hooks": "src/hooks"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "@json-render/shadcn",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
"keywords": [
"json",
"ui",
"react",
"shadcn",
"tailwind",
"radix",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"components"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/shadcn"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./catalog": {
"types": "./dist/catalog.d.ts",
"import": "./dist/catalog.mjs",
"require": "./dist/catalog.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.564.0",
"radix-ui": "^1.4.3",
"tailwind-merge": "^3.4.1",
"vaul": "^1.1.2"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zod": "^4.0.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"zod": "^4.0.0"
}
}
+502
View File
@@ -0,0 +1,502 @@
import { z } from "zod";
// =============================================================================
// shadcn/ui Component Definitions
// =============================================================================
/**
* shadcn/ui component definitions for json-render catalogs.
*
* These can be used directly or extended with custom components.
* All components are built using Radix UI primitives + Tailwind CSS.
*/
export const shadcnComponentDefinitions = {
// ==========================================================================
// Layout Components
// ==========================================================================
Card: {
props: z.object({
title: z.string().nullable(),
description: z.string().nullable(),
maxWidth: z.enum(["sm", "md", "lg", "full"]).nullable(),
centered: z.boolean().nullable(),
}),
slots: ["default"],
description:
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
example: { title: "Overview", description: "Your account summary" },
},
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).nullable(),
gap: z.enum(["none", "sm", "md", "lg"]).nullable(),
align: z.enum(["start", "center", "end", "stretch"]).nullable(),
justify: z
.enum(["start", "center", "end", "between", "around"])
.nullable(),
}),
slots: ["default"],
description: "Flex container for layouts",
example: { direction: "vertical", gap: "md" },
},
Grid: {
props: z.object({
columns: z.number().nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
}),
slots: ["default"],
description: "Grid layout (1-6 columns)",
example: { columns: 3, gap: "md" },
},
Separator: {
props: z.object({
orientation: z.enum(["horizontal", "vertical"]).nullable(),
}),
description: "Visual separator line",
},
Tabs: {
props: z.object({
tabs: z.array(
z.object({
label: z.string(),
value: z.string(),
}),
),
defaultValue: z.string().nullable(),
value: z.string().nullable(),
}),
slots: ["default"],
events: ["change"],
description:
"Tab navigation. Use { $bindState } on value for active tab binding.",
},
Accordion: {
props: z.object({
items: z.array(
z.object({
title: z.string(),
content: z.string(),
}),
),
type: z.enum(["single", "multiple"]).nullable(),
}),
description:
"Collapsible sections. Items as [{title, content}]. Type 'single' (default) or 'multiple'.",
},
Collapsible: {
props: z.object({
title: z.string(),
defaultOpen: z.boolean().nullable(),
}),
slots: ["default"],
description: "Collapsible section with trigger. Children render inside.",
},
Dialog: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
openPath: z.string(),
}),
slots: ["default"],
description:
"Modal dialog. Set openPath to a boolean state path. Use setState to toggle.",
},
Drawer: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
openPath: z.string(),
}),
slots: ["default"],
description:
"Bottom sheet drawer. Set openPath to a boolean state path. Use setState to toggle.",
},
Carousel: {
props: z.object({
items: z.array(
z.object({
title: z.string().nullable(),
description: z.string().nullable(),
}),
),
}),
description: "Horizontally scrollable carousel of cards.",
},
// ==========================================================================
// Data Display Components
// ==========================================================================
Table: {
props: z.object({
columns: z.array(z.string()),
rows: z.array(z.array(z.string())),
caption: z.string().nullable(),
}),
description:
'Data table. columns: header labels. rows: 2D array of cell strings, e.g. [["Alice","admin"],["Bob","user"]].',
example: {
columns: ["Name", "Role"],
rows: [
["Alice", "Admin"],
["Bob", "User"],
],
},
},
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
}),
description: "Heading text (h1-h4)",
example: { text: "Welcome", level: "h1" },
},
Text: {
props: z.object({
text: z.string(),
variant: z.enum(["body", "caption", "muted", "lead", "code"]).nullable(),
}),
description: "Paragraph text",
example: { text: "Hello, world!" },
},
Image: {
props: z.object({
src: z.string().nullable(),
alt: z.string(),
width: z.number().nullable(),
height: z.number().nullable(),
}),
description:
"Image component. Renders an img tag when src is provided, otherwise a placeholder.",
},
Avatar: {
props: z.object({
src: z.string().nullable(),
name: z.string(),
size: z.enum(["sm", "md", "lg"]).nullable(),
}),
description: "User avatar with fallback initials",
example: { name: "Jane Doe", size: "md" },
},
Badge: {
props: z.object({
text: z.string(),
variant: z
.enum(["default", "secondary", "destructive", "outline"])
.nullable(),
}),
description: "Status badge",
example: { text: "Active", variant: "default" },
},
Alert: {
props: z.object({
title: z.string(),
message: z.string().nullable(),
type: z.enum(["info", "success", "warning", "error"]).nullable(),
}),
description: "Alert banner",
example: {
title: "Note",
message: "Your changes have been saved.",
type: "success",
},
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().nullable(),
label: z.string().nullable(),
}),
description: "Progress bar (value 0-100)",
example: { value: 65, max: 100, label: "Upload progress" },
},
Skeleton: {
props: z.object({
width: z.string().nullable(),
height: z.string().nullable(),
rounded: z.boolean().nullable(),
}),
description: "Loading placeholder skeleton",
},
Spinner: {
props: z.object({
size: z.enum(["sm", "md", "lg"]).nullable(),
label: z.string().nullable(),
}),
description: "Loading spinner indicator",
},
Tooltip: {
props: z.object({
content: z.string(),
text: z.string(),
}),
description: "Hover tooltip. Shows content on hover over text.",
},
Popover: {
props: z.object({
trigger: z.string(),
content: z.string(),
}),
description: "Popover that appears on click of trigger.",
},
// ==========================================================================
// Form Input Components
// ==========================================================================
Input: {
props: z.object({
label: z.string(),
name: z.string(),
type: z.enum(["text", "email", "password", "number"]).nullable(),
placeholder: z.string().nullable(),
value: z.string().nullable(),
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
events: ["submit", "focus", "blur"],
description:
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
example: {
label: "Email",
name: "email",
type: "email",
placeholder: "you@example.com",
},
},
Textarea: {
props: z.object({
label: z.string(),
name: z.string(),
placeholder: z.string().nullable(),
rows: z.number().nullable(),
value: z.string().nullable(),
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
description:
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
},
Select: {
props: z.object({
label: z.string(),
name: z.string(),
options: z.array(z.string()),
placeholder: z.string().nullable(),
value: z.string().nullable(),
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
events: ["change"],
description:
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
},
Checkbox: {
props: z.object({
label: z.string(),
name: z.string(),
checked: z.boolean().nullable(),
}),
events: ["change"],
description: "Checkbox input. Use { $bindState } on checked for binding.",
},
Radio: {
props: z.object({
label: z.string(),
name: z.string(),
options: z.array(z.string()),
value: z.string().nullable(),
}),
events: ["change"],
description: "Radio button group. Use { $bindState } on value for binding.",
},
Switch: {
props: z.object({
label: z.string(),
name: z.string(),
checked: z.boolean().nullable(),
}),
events: ["change"],
description: "Toggle switch. Use { $bindState } on checked for binding.",
},
Slider: {
props: z.object({
label: z.string().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
step: z.number().nullable(),
value: z.number().nullable(),
}),
events: ["change"],
description: "Range slider input. Use { $bindState } on value for binding.",
},
// ==========================================================================
// Action Components
// ==========================================================================
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).nullable(),
disabled: z.boolean().nullable(),
}),
events: ["press"],
description: "Clickable button. Bind on.press for handler.",
example: { label: "Submit", variant: "primary" },
},
Link: {
props: z.object({
label: z.string(),
href: z.string(),
}),
events: ["press"],
description: "Anchor link. Bind on.press for click handler.",
},
DropdownMenu: {
props: z.object({
label: z.string(),
items: z.array(
z.object({
label: z.string(),
value: z.string(),
}),
),
value: z.string().nullable(),
}),
events: ["select"],
description:
"Dropdown menu with trigger button and selectable items. Use { $bindState } on value for selected item binding.",
},
Toggle: {
props: z.object({
label: z.string(),
pressed: z.boolean().nullable(),
variant: z.enum(["default", "outline"]).nullable(),
}),
events: ["change"],
description:
"Toggle button. Use { $bindState } on pressed for state binding.",
},
ToggleGroup: {
props: z.object({
items: z.array(
z.object({
label: z.string(),
value: z.string(),
}),
),
type: z.enum(["single", "multiple"]).nullable(),
value: z.string().nullable(),
}),
events: ["change"],
description:
"Group of toggle buttons. Type 'single' (default) or 'multiple'. Use { $bindState } on value.",
},
ButtonGroup: {
props: z.object({
buttons: z.array(
z.object({
label: z.string(),
value: z.string(),
}),
),
selected: z.string().nullable(),
}),
events: ["change"],
description:
"Segmented button group. Use { $bindState } on selected for selected value.",
},
Pagination: {
props: z.object({
totalPages: z.number(),
page: z.number().nullable(),
}),
events: ["change"],
description:
"Page navigation. Use { $bindState } on page for current page number.",
},
};
// =============================================================================
// Types
// =============================================================================
/**
* Type for a component definition
*/
export type ComponentDefinition = {
props: z.ZodType;
slots?: string[];
events?: string[];
description: string;
example?: Record<string, unknown>;
};
/**
* Infer the props type for a shadcn component by name.
* Derives the TypeScript type directly from the Zod schema,
* so component implementations stay in sync with catalog definitions.
*
* @example
* ```ts
* type CardProps = ShadcnProps<"Card">;
* // { title: string | null; description: string | null; ... }
* ```
*/
export type ShadcnProps<K extends keyof typeof shadcnComponentDefinitions> =
z.output<(typeof shadcnComponentDefinitions)[K]["props"]>;
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
// Component implementations
export { shadcnComponents } from "./components";
// Catalog definitions (also available via @json-render/shadcn/catalog)
export {
shadcnComponentDefinitions,
type ComponentDefinition,
type ShadcnProps,
} from "./catalog";
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+53
View File
@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "../lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+64
View File
@@ -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 };
+78
View File
@@ -0,0 +1,78 @@
import * as React from "react";
import { cn } from "../lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
+241
View File
@@ -0,0 +1,241 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "../lib/utils";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+32
View File
@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+158
View File
@@ -0,0 +1,158 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
import { Button } from "./button";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+133
View File
@@ -0,0 +1,133 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "../lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-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 DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
+257
View File
@@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "../lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className,
)}
{...props}
/>
);
}
export { Input };
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "../lib/utils";
import { buttonVariants, type Button } from "./button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
+89
View File
@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
};
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { CircleIcon } from "lucide-react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary 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 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };
+188
View File
@@ -0,0 +1,188 @@
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+28
View File
@@ -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 };
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "../lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
+63
View File
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };
+35
View File
@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react";
import { cn } from "../lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+91
View File
@@ -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 };
+18
View File
@@ -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 };
+83
View File
@@ -0,0 +1,83 @@
"use client";
import * as React from "react";
import { type VariantProps } from "class-variance-authority";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
import { toggleVariants } from "./toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
>({
size: "default",
variant: "default",
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cn } from "../lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
+57
View File
@@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "../lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/catalog.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: [
"react",
"react-dom",
"@json-render/core",
"@json-render/react",
"zod",
],
});
+243 -169
View File
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
---
name: json-render-shadcn
description: Pre-built shadcn/ui components for json-render. Use when working with @json-render/shadcn, adding standard UI components to a catalog, or building web UIs with Radix UI + Tailwind CSS components.
---
# @json-render/shadcn
Pre-built shadcn/ui component definitions and implementations for json-render. Provides 30+ components built on Radix UI + Tailwind CSS.
## Two Entry Points
| Entry Point | Exports | Use For |
|-------------|---------|---------|
| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` | Catalog schemas (no React dependency, safe for server) |
| `@json-render/shadcn` | `shadcnComponents` | React implementations |
## Usage Pattern
Pick the components you need from the standard definitions. Do not spread all definitions -- explicitly select what your app uses:
```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. No need to declare them.
## Extending with Custom Components
Add custom components alongside standard ones:
```typescript
const catalog = defineCatalog(schema, {
components: {
// Standard
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
// 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,
Metric: ({ props }) => <div>{props.label}: {props.value}</div>,
},
});
```
## Available Components
### Layout
- **Card** - Container with optional title, description, maxWidth, centered
- **Stack** - Flex container with direction, gap, align, justify
- **Grid** - Grid layout with columns (number) and gap
- **Separator** - Visual divider with orientation
### Navigation
- **Tabs** - Tabbed navigation with tabs array, defaultValue, value
- **Accordion** - Collapsible sections with items array and type (single/multiple)
- **Collapsible** - Single collapsible section with title
- **Pagination** - Page navigation with totalPages and page
### Overlay
- **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 with label and items array
### Content
- **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 (default, secondary, destructive, outline)
- **Alert** - Alert banner with title, message, type (success, warning, info, error)
- **Carousel** - Scrollable carousel with items array
- **Table** - Data table with columns (string[]) and rows (string[][])
### Feedback
- **Progress** - Progress bar with value, max, label
- **Skeleton** - Loading placeholder with width, height, rounded
- **Spinner** - Loading spinner with size and label
### Input
- **Button** - Button with label, variant (primary, secondary, danger), disabled
- **Link** - Anchor link with label and href
- **Input** - Text input with label, name, type, placeholder, value, checks
- **Textarea** - Multi-line input with label, name, placeholder, rows, value, checks
- **Select** - Dropdown select with label, name, options (string[]), value, checks
- **Checkbox** - Checkbox with label, name, checked
- **Radio** - Radio group with label, name, options (string[]), value
- **Switch** - Toggle switch with label, name, checked
- **Slider** - Range slider with label, min, max, step, value
- **Toggle** - Toggle button with label, pressed, variant
- **ToggleGroup** - Group of toggles with items, type, value
- **ButtonGroup** - Button group with buttons array and selected
## Built-in Actions (from `@json-render/react`)
These are built into the React schema and handled by `ActionProvider` automatically. They appear in prompts without needing to be declared in the catalog.
- **setState** - Set a value at a state path (`{ statePath, value }`)
- **pushState** - Push a value onto an array (`{ statePath, value, clearStatePath? }`)
- **removeState** - Remove an array item by index (`{ statePath, index }`)
## Important Notes
- The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
- Components use Tailwind CSS classes -- your app must have Tailwind configured
- Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
- Form inputs support `checks` for validation (type + message pairs)
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`