Pārlūkot izejas kodu

defineRegistry (#64)

* defineRegistry

* update dashboard example
Chris Tate 5 mēneši atpakaļ
vecāks
revīzija
795cdaac56

+ 28 - 32
README.md

@@ -56,45 +56,39 @@ const catalog = defineCatalog(schema, {
 });
 ```
 
-### 2. Register Component Implementations
+### 2. Define Your Components
 
 ```tsx
-import { defineComponents } from "@json-render/react";
-
-const components = defineComponents(catalog, {
-  Card: ({ props, children }) => (
-    <div className="card">
-      <h3>{props.title}</h3>
-      {children}
-    </div>
-  ),
-  Metric: ({ props }) => (
-    <div className="metric">
-      <span>{props.label}</span>
-      <span>{format(props.value, props.format)}</span>
-    </div>
-  ),
-  Button: ({ props, onAction }) => (
-    <button onClick={() => onAction?.(props.action)}>
-      {props.label}
-    </button>
-  ),
+import { defineRegistry, Renderer } from "@json-render/react";
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => (
+      <div className="card">
+        <h3>{props.title}</h3>
+        {children}
+      </div>
+    ),
+    Metric: ({ props }) => (
+      <div className="metric">
+        <span>{props.label}</span>
+        <span>{format(props.value, props.format)}</span>
+      </div>
+    ),
+    Button: ({ props, onAction }) => (
+      <button onClick={() => onAction?.({ name: props.action })}>
+        {props.label}
+      </button>
+    ),
+  },
 });
 ```
 
 ### 3. Render AI-Generated Specs
 
 ```tsx
-import { Renderer } from "@json-render/react";
-
 function Dashboard({ spec }) {
-  return (
-    <Renderer
-      spec={spec}
-      catalog={catalog}
-      components={components}
-    />
-  );
+  return <Renderer spec={spec} registry={registry} />;
 }
 ```
 
@@ -115,7 +109,7 @@ function Dashboard({ spec }) {
 ### React (UI)
 
 ```tsx
-import { Renderer } from "@json-render/react";
+import { defineRegistry, Renderer } from "@json-render/react";
 import { schema } from "@json-render/react";
 
 // Element tree spec format
@@ -129,7 +123,9 @@ const spec = {
   }
 };
 
-<Renderer spec={spec} catalog={catalog} components={components} />
+// defineRegistry creates a type-safe component registry
+const { registry } = defineRegistry(catalog, { components });
+<Renderer spec={spec} registry={registry} />
 ```
 
 ### Remotion (Video)

+ 30 - 15
apps/web/app/(main)/docs/api/react/page.tsx

@@ -43,36 +43,51 @@ interface AuthState {
 
 type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">createRenderer</h2>
+      <h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
       <p className="text-sm text-muted-foreground mb-4">
-        Factory function to create a pre-configured renderer component.
+        Create a type-safe component registry from a catalog. Components receive{" "}
+        <code className="text-foreground">props</code>,{" "}
+        <code className="text-foreground">children</code>,{" "}
+        <code className="text-foreground">onAction</code>, and{" "}
+        <code className="text-foreground">loading</code> with catalog-inferred
+        types.
       </p>
-      <Code lang="tsx">{`import { createRenderer } from '@json-render/react';
-
-const MyRenderer = createRenderer({
-  registry: componentRegistry,
-  data?: initialData,
-  actionHandlers?: actionHandlerMap,
-  auth?: authState,
+      <Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => <div>{props.title}{children}</div>,
+    Button: ({ props, onAction }) => (
+      <button onClick={() => onAction?.({ name: props.action })}>
+        {props.label}
+      </button>
+    ),
+  },
 });
 
-// Usage
-<MyRenderer spec={spec} loading={isStreaming} />`}</Code>
+// Pass to <Renderer>
+<Renderer spec={spec} registry={registry} />`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
       <Code lang="tsx">{`<Renderer
   spec={Spec}           // The UI spec to render
-  registry={Registry}   // Component registry
+  registry={Registry}   // Component registry (from defineRegistry)
   loading={boolean}     // Optional loading state
+  fallback={Component}  // Optional fallback for unknown types
 />
 
-type Registry = Record<string, React.ComponentType<ComponentProps>>;
+type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Code>
 
-interface ComponentProps<T = Record<string, unknown>> {
-  props: T;                    // Component props from spec
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        Component Props (via defineRegistry)
+      </h3>
+      <Code lang="tsx">{`interface ComponentContext<P> {
+  props: P;                    // Typed props from catalog
   children?: React.ReactNode;  // Rendered children (for slot components)
+  onAction?: (action: { name: string; params?: object }) => void;
+  loading?: boolean;
 }`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>

+ 34 - 31
apps/web/app/(main)/docs/quick-start/page.tsx

@@ -61,39 +61,42 @@ export const catalog = defineCatalog(schema, {
 });`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">
-        2. Create your components and actions
+        2. Define your components
       </h2>
       <p className="text-sm text-muted-foreground mb-4">
-        Define React components that render each catalog type. Each component
-        receives <code className="text-foreground">props</code>,{" "}
+        Use <code className="text-foreground">defineRegistry</code> to map
+        catalog types to React components. Each component receives type-safe{" "}
+        <code className="text-foreground">props</code>,{" "}
         <code className="text-foreground">children</code>, and{" "}
         <code className="text-foreground">onAction</code>:
       </p>
-      <Code lang="tsx">{`// lib/components.tsx
-import { defineComponents } from '@json-render/react';
+      <Code lang="tsx">{`// lib/registry.tsx
+import { defineRegistry } from '@json-render/react';
 import { catalog } from './catalog';
 
-export const components = defineComponents(catalog, {
-  Card: ({ props, children }) => (
-    <div className="p-4 border rounded-lg">
-      <h2 className="font-bold">{props.title}</h2>
-      {props.description && (
-        <p className="text-gray-600">{props.description}</p>
-      )}
-      {children}
-    </div>
-  ),
-  Button: ({ props, onAction }) => (
-    <button
-      className="px-4 py-2 bg-blue-500 text-white rounded"
-      onClick={() => onAction?.(props.action)}
-    >
-      {props.label}
-    </button>
-  ),
-  Text: ({ props }) => (
-    <p>{props.content}</p>
-  ),
+export const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => (
+      <div className="p-4 border rounded-lg">
+        <h2 className="font-bold">{props.title}</h2>
+        {props.description && (
+          <p className="text-gray-600">{props.description}</p>
+        )}
+        {children}
+      </div>
+    ),
+    Button: ({ props, onAction }) => (
+      <button
+        className="px-4 py-2 bg-blue-500 text-white rounded"
+        onClick={() => onAction?.({ name: props.action })}
+      >
+        {props.label}
+      </button>
+    ),
+    Text: ({ props }) => (
+      <p>{props.content}</p>
+    ),
+  },
 });`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">
@@ -123,14 +126,14 @@ export async function POST(req: Request) {
 
       <h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
       <p className="text-sm text-muted-foreground mb-4">
-        Use the providers and renderer to display AI-generated UI:
+        Use providers and the <code className="text-foreground">Renderer</code>{" "}
+        with your registry to display AI-generated UI:
       </p>
       <Code lang="tsx">{`// app/page.tsx
 'use client';
 
-import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
-import { catalog } from '@/lib/catalog';
-import { components } from '@/lib/components';
+import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
+import { registry } from '@/lib/registry';
 
 export default function Page() {
   const { spec, isStreaming, send } = useUIStream({
@@ -162,7 +165,7 @@ export default function Page() {
           </form>
 
           <div className="mt-8">
-            <Renderer spec={spec} catalog={catalog} components={components} loading={isStreaming} />
+            <Renderer spec={spec} registry={registry} loading={isStreaming} />
           </div>
         </ActionProvider>
       </VisibilityProvider>

+ 1 - 1
examples/dashboard/components/widget.tsx

@@ -13,7 +13,7 @@ import {
 } from "lucide-react";
 import { useUIStream, type Spec } from "@json-render/react";
 import { DashboardRenderer } from "@/lib/render/renderer";
-import { executeAction } from "@/lib/render/catalog/actions";
+import { executeAction } from "@/lib/render/registry";
 import { CodeHighlight } from "@/components/code-highlight";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";

+ 0 - 255
examples/dashboard/lib/render/catalog/actions.ts

@@ -1,255 +0,0 @@
-import { toast } from "sonner";
-import { findFormValue } from "@json-render/core";
-import {
-  type Actions,
-  type ActionFn,
-  type SetData,
-  type DataModel,
-} from "@json-render/react";
-import { dashboardCatalog } from "../catalog";
-
-// =============================================================================
-// Action Handlers - Type-safe with Catalog
-// =============================================================================
-
-export const actionHandlers: Actions<typeof dashboardCatalog> = {
-  viewCustomers: async (params, setData) => {
-    const queryParams = new URLSearchParams();
-    if (params?.limit) queryParams.set("limit", String(params.limit));
-    if (params?.sort) queryParams.set("sort", String(params.sort));
-    if (params?.status) queryParams.set("status", String(params.status));
-    const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
-    const res = await fetch(url);
-    const customers = await res.json();
-    setData((prev) => ({ ...prev, customers }));
-  },
-
-  refreshCustomers: async (params, setData) => {
-    const queryParams = new URLSearchParams();
-    if (params?.limit) queryParams.set("limit", String(params.limit));
-    if (params?.sort) queryParams.set("sort", String(params.sort));
-    const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
-    const res = await fetch(url);
-    const customers = await res.json();
-    setData((prev) => ({ ...prev, customers }));
-  },
-
-  createCustomer: async (params, setData, data) => {
-    const name = findFormValue("name", params, data) as string;
-    const email =
-      (findFormValue("email", params, data) as string) ||
-      `${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
-    const phone = findFormValue("phone", params, data) as string | undefined;
-
-    if (!name) {
-      toast.error("Customer name is required");
-      return;
-    }
-
-    try {
-      const res = await fetch("/api/v1/customers", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ name, email, phone }),
-      });
-      const customer = await res.json();
-      if (res.ok) {
-        toast.success(`Customer "${customer.name}" created`);
-        const listRes = await fetch("/api/v1/customers");
-        const customers = await listRes.json();
-        setData((prev) => ({ ...prev, customers }));
-      } else {
-        toast.error(customer.error || "Failed to create customer");
-      }
-    } catch (err) {
-      console.error("Failed to create customer:", err);
-      toast.error("Failed to create customer");
-    }
-  },
-
-  deleteCustomer: async (params, setData, data) => {
-    const customerId =
-      findFormValue("customerId", params, data) ||
-      findFormValue("id", params, data);
-    if (!customerId) {
-      toast.error("Customer ID required");
-      return;
-    }
-    try {
-      const res = await fetch(`/api/v1/customers/${customerId}`, {
-        method: "DELETE",
-      });
-      if (res.ok) {
-        toast.success("Customer deleted");
-        const listRes = await fetch("/api/v1/customers");
-        const customers = await listRes.json();
-        setData((prev) => ({ ...prev, customers }));
-      } else {
-        const err = await res.json();
-        toast.error(err.error || "Failed to delete customer");
-      }
-    } catch (err) {
-      console.error("Failed to delete customer:", err);
-      toast.error("Failed to delete customer");
-    }
-  },
-
-  viewInvoices: async (params, setData) => {
-    const queryParams = new URLSearchParams();
-    if (params?.status) queryParams.set("status", String(params.status));
-    const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
-    const res = await fetch(url);
-    const invoices = await res.json();
-    setData((prev) => ({ ...prev, invoices }));
-  },
-
-  refreshInvoices: async (_params, setData) => {
-    const res = await fetch("/api/v1/invoices");
-    const invoices = await res.json();
-    setData((prev) => ({ ...prev, invoices }));
-  },
-
-  createInvoice: async (params, setData) => {
-    if (!params?.customerId || !params?.dueDate) {
-      toast.error("Customer ID and due date required");
-      return;
-    }
-    try {
-      const res = await fetch("/api/v1/invoices", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify(params),
-      });
-      const invoice = await res.json();
-      if (res.ok) {
-        toast.success("Invoice created");
-        const listRes = await fetch("/api/v1/invoices");
-        const invoices = await listRes.json();
-        setData((prev) => ({ ...prev, invoices }));
-      } else {
-        toast.error(invoice.error || "Failed to create invoice");
-      }
-    } catch (err) {
-      console.error("Failed to create invoice:", err);
-      toast.error("Failed to create invoice");
-    }
-  },
-
-  sendInvoice: async (params) => {
-    if (!params?.invoiceId) {
-      toast.error("Invoice ID required");
-      return;
-    }
-    const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
-      method: "POST",
-    });
-    const result = await res.json();
-    if (res.ok) {
-      toast.success(result.message || "Invoice sent");
-    } else {
-      toast.error(result.error || "Failed to send invoice");
-    }
-  },
-
-  markInvoicePaid: async (params) => {
-    if (!params?.invoiceId) {
-      toast.error("Invoice ID required");
-      return;
-    }
-    const res = await fetch(`/api/v1/invoices/${params.invoiceId}/mark-paid`, {
-      method: "POST",
-    });
-    const result = await res.json();
-    if (res.ok) {
-      toast.success(result.message || "Invoice marked paid");
-    } else {
-      toast.error(result.error || "Failed to mark invoice paid");
-    }
-  },
-
-  viewExpenses: async (params, setData) => {
-    const queryParams = new URLSearchParams();
-    if (params?.status) queryParams.set("status", String(params.status));
-    const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
-    const res = await fetch(url);
-    const expenses = await res.json();
-    setData((prev) => ({ ...prev, expenses }));
-  },
-
-  refreshExpenses: async (_params, setData) => {
-    const res = await fetch("/api/v1/expenses");
-    const expenses = await res.json();
-    setData((prev) => ({ ...prev, expenses }));
-  },
-
-  createExpense: async (params, setData) => {
-    if (!params?.vendor || !params?.category || params?.amount === undefined) {
-      toast.error("Vendor, category, and amount required");
-      return;
-    }
-    try {
-      const res = await fetch("/api/v1/expenses", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify(params),
-      });
-      const expense = await res.json();
-      if (res.ok) {
-        toast.success("Expense created");
-        const listRes = await fetch("/api/v1/expenses");
-        const expenses = await listRes.json();
-        setData((prev) => ({ ...prev, expenses }));
-      } else {
-        toast.error(expense.error || "Failed to create expense");
-      }
-    } catch (err) {
-      console.error("Failed to create expense:", err);
-      toast.error("Failed to create expense");
-    }
-  },
-
-  approveExpense: async (params) => {
-    if (!params?.expenseId) {
-      toast.error("Expense ID required");
-      return;
-    }
-    const res = await fetch(`/api/v1/expenses/${params.expenseId}/approve`, {
-      method: "POST",
-    });
-    const result = await res.json();
-    if (res.ok) {
-      toast.success(result.message || "Expense approved");
-    } else {
-      toast.error(result.error || "Failed to approve expense");
-    }
-  },
-};
-
-// =============================================================================
-// Execute Action
-// =============================================================================
-
-type Catalog = typeof dashboardCatalog;
-type CatalogActions = Catalog["data"]["actions"];
-
-/**
- * Execute an action by name with the given parameters.
- */
-export async function executeAction(
-  actionName: string,
-  params: Record<string, unknown> | undefined,
-  setData: SetData,
-  data: DataModel = {},
-): Promise<void> {
-  const handler = actionHandlers[actionName as keyof CatalogActions];
-  if (handler) {
-    await (handler as ActionFn<Catalog, keyof CatalogActions>)(
-      params as never,
-      setData,
-      data,
-    );
-  } else {
-    console.log("Unknown action:", actionName, params);
-    toast(`Action: ${actionName}`);
-  }
-}

+ 0 - 771
examples/dashboard/lib/render/catalog/components.tsx

@@ -1,771 +0,0 @@
-"use client";
-
-import { useData, type Components } from "@json-render/react";
-import { getByPath } from "@json-render/core";
-import {
-  Bar,
-  BarChart as RechartsBarChart,
-  CartesianGrid,
-  Line,
-  LineChart as RechartsLineChart,
-  XAxis,
-} from "recharts";
-import {
-  ChartContainer,
-  ChartTooltip,
-  ChartTooltipContent,
-  type ChartConfig,
-} from "@/components/ui/chart";
-
-// shadcn components
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Badge } from "@/components/ui/badge";
-import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
-import { Separator } from "@/components/ui/separator";
-import {
-  Accordion,
-  AccordionItem,
-  AccordionTrigger,
-  AccordionContent,
-} from "@/components/ui/accordion";
-import {
-  Table,
-  TableHeader,
-  TableBody,
-  TableHead,
-  TableRow,
-  TableCell,
-} from "@/components/ui/table";
-import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
-import { Checkbox } from "@/components/ui/checkbox";
-import {
-  Dialog,
-  DialogTrigger,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-  DialogDescription,
-} from "@/components/ui/dialog";
-import {
-  Drawer,
-  DrawerTrigger,
-  DrawerContent,
-  DrawerHeader,
-  DrawerTitle,
-  DrawerDescription,
-} from "@/components/ui/drawer";
-import {
-  DropdownMenu,
-  DropdownMenuTrigger,
-  DropdownMenuContent,
-  DropdownMenuItem,
-} from "@/components/ui/dropdown-menu";
-import {
-  Pagination,
-  PaginationContent,
-  PaginationItem,
-  PaginationPrevious,
-  PaginationNext,
-  PaginationLink,
-} from "@/components/ui/pagination";
-import {
-  Popover,
-  PopoverTrigger,
-  PopoverContent,
-} from "@/components/ui/popover";
-import { Progress } from "@/components/ui/progress";
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
-import {
-  Select,
-  SelectTrigger,
-  SelectValue,
-  SelectContent,
-  SelectItem,
-} from "@/components/ui/select";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Switch } from "@/components/ui/switch";
-import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
-import { Textarea } from "@/components/ui/textarea";
-import {
-  Tooltip,
-  TooltipTrigger,
-  TooltipContent,
-  TooltipProvider,
-} from "@/components/ui/tooltip";
-
-import { dashboardCatalog } from "../catalog";
-
-// =============================================================================
-// Components - Type-safe with Catalog
-// =============================================================================
-
-export const components: Components<typeof dashboardCatalog> = {
-  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"} ${gapClass}`}
-      >
-        {children}
-      </div>
-    );
-  },
-
-  Accordion: ({ props, children }) => (
-    <Accordion type={props.type ?? "single"} collapsible>
-      {children}
-    </Accordion>
-  ),
-
-  AccordionItem: ({ props, children }) => (
-    <AccordionItem value={props.value}>
-      <AccordionTrigger>{props.title}</AccordionTrigger>
-      <AccordionContent>{children}</AccordionContent>
-    </AccordionItem>
-  ),
-
-  Button: ({ props, onAction, loading }) => (
-    <Button
-      variant={props.variant ?? "default"}
-      disabled={loading || (props.disabled ?? false)}
-      onClick={() =>
-        onAction?.({
-          name: props.action,
-          params: props.actionParams ?? undefined,
-        })
-      }
-    >
-      {loading ? "..." : props.label}
-    </Button>
-  ),
-
-  Input: ({ props }) => {
-    const { data, set } = useData();
-    return (
-      <div className="flex flex-col gap-2">
-        {props.label ? <Label>{props.label}</Label> : null}
-        <Input
-          type={props.type ?? "text"}
-          value={(getByPath(data, props.valuePath) as string) ?? ""}
-          placeholder={props.placeholder ?? ""}
-          onChange={(e) => set(props.valuePath, e.target.value)}
-        />
-      </div>
-    );
-  },
-
-  Form: ({ props, children, onAction }) => (
-    <form
-      onSubmit={(e) => {
-        e.preventDefault();
-        onAction?.({
-          name: props.submitAction,
-          params: props.submitActionParams ?? undefined,
-        });
-      }}
-      className="flex flex-col gap-4"
-    >
-      {children}
-    </form>
-  ),
-
-  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 />,
-
-  Avatar: ({ props }) => (
-    <Avatar>
-      {props.src ? <AvatarImage src={props.src} alt={props.alt ?? ""} /> : null}
-      <AvatarFallback>{props.fallback}</AvatarFallback>
-    </Avatar>
-  ),
-
-  Checkbox: ({ props }) => {
-    const { data, set } = useData();
-    const checked =
-      (getByPath(data, props.valuePath) as boolean) ??
-      props.defaultChecked ??
-      false;
-    return (
-      <div className="flex items-center gap-2">
-        <Checkbox
-          id={props.valuePath}
-          checked={checked}
-          onCheckedChange={(value) => set(props.valuePath, value)}
-        />
-        {props.label ? (
-          <Label htmlFor={props.valuePath}>{props.label}</Label>
-        ) : null}
-      </div>
-    );
-  },
-
-  Dialog: ({ props, children }) => (
-    <Dialog>
-      <DialogTrigger asChild>
-        <Button variant="outline">{props.trigger}</Button>
-      </DialogTrigger>
-      <DialogContent>
-        <DialogHeader>
-          <DialogTitle>{props.title}</DialogTitle>
-          {props.description ? (
-            <DialogDescription>{props.description}</DialogDescription>
-          ) : null}
-        </DialogHeader>
-        {children}
-      </DialogContent>
-    </Dialog>
-  ),
-
-  Drawer: ({ props, children }) => (
-    <Drawer>
-      <DrawerTrigger asChild>
-        <Button variant="outline">{props.trigger}</Button>
-      </DrawerTrigger>
-      <DrawerContent>
-        <DrawerHeader>
-          <DrawerTitle>{props.title}</DrawerTitle>
-          {props.description ? (
-            <DrawerDescription>{props.description}</DrawerDescription>
-          ) : null}
-        </DrawerHeader>
-        <div className="p-4">{children}</div>
-      </DrawerContent>
-    </Drawer>
-  ),
-
-  DropdownMenu: ({ props, onAction }) => (
-    <DropdownMenu>
-      <DropdownMenuTrigger asChild>
-        <Button variant="outline">{props.trigger}</Button>
-      </DropdownMenuTrigger>
-      <DropdownMenuContent>
-        {props.items.map((item, i) => (
-          <DropdownMenuItem
-            key={i}
-            onClick={() =>
-              item.action
-                ? onAction?.({
-                    name: item.action,
-                    params: item.actionParams ?? undefined,
-                  })
-                : undefined
-            }
-          >
-            {item.label}
-          </DropdownMenuItem>
-        ))}
-      </DropdownMenuContent>
-    </DropdownMenu>
-  ),
-
-  Label: ({ props }) => (
-    <Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
-  ),
-
-  Pagination: ({ props }) => {
-    const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
-    return (
-      <Pagination>
-        <PaginationContent>
-          <PaginationItem>
-            <PaginationPrevious href="#" />
-          </PaginationItem>
-          {pages.map((page) => (
-            <PaginationItem key={page}>
-              <PaginationLink href="#" isActive={page === props.currentPage}>
-                {page}
-              </PaginationLink>
-            </PaginationItem>
-          ))}
-          <PaginationItem>
-            <PaginationNext href="#" />
-          </PaginationItem>
-        </PaginationContent>
-      </Pagination>
-    );
-  },
-
-  Popover: ({ props, children }) => (
-    <Popover>
-      <PopoverTrigger asChild>
-        <Button variant="outline">{props.trigger}</Button>
-      </PopoverTrigger>
-      <PopoverContent>{children}</PopoverContent>
-    </Popover>
-  ),
-
-  Progress: ({ props }) => (
-    <Progress value={props.value} max={props.max ?? 100} />
-  ),
-
-  RadioGroup: ({ props }) => {
-    const { data, set } = useData();
-    const value =
-      (getByPath(data, props.valuePath) as string) ?? props.defaultValue ?? "";
-    return (
-      <RadioGroup value={value} onValueChange={(v) => set(props.valuePath, v)}>
-        {props.options.map((option) => (
-          <div key={option.value} className="flex items-center gap-2">
-            <RadioGroupItem value={option.value} id={option.value} />
-            <Label htmlFor={option.value}>{option.label}</Label>
-          </div>
-        ))}
-      </RadioGroup>
-    );
-  },
-
-  Select: ({ props }) => {
-    const { data, set } = useData();
-    const value = (getByPath(data, props.valuePath) as string) ?? "";
-    return (
-      <Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
-        <SelectTrigger>
-          <SelectValue placeholder={props.placeholder ?? "Select..."} />
-        </SelectTrigger>
-        <SelectContent>
-          {props.options.map((option) => (
-            <SelectItem key={option.value} value={option.value}>
-              {option.label}
-            </SelectItem>
-          ))}
-        </SelectContent>
-      </Select>
-    );
-  },
-
-  Skeleton: ({ props }) => (
-    <Skeleton
-      className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
-    />
-  ),
-
-  Spinner: ({ props }) => {
-    const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
-    const size = sizes[props.size ?? "md"];
-    return (
-      <div
-        className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
-      />
-    );
-  },
-
-  Switch: ({ props }) => {
-    const { data, set } = useData();
-    const checked =
-      (getByPath(data, props.valuePath) as boolean) ??
-      props.defaultChecked ??
-      false;
-    return (
-      <div className="flex items-center gap-2">
-        <Switch
-          id={props.valuePath}
-          checked={checked}
-          onCheckedChange={(value) => set(props.valuePath, value)}
-        />
-        {props.label ? (
-          <Label htmlFor={props.valuePath}>{props.label}</Label>
-        ) : null}
-      </div>
-    );
-  },
-
-  Tabs: ({ props, children }) => (
-    <Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
-      <TabsList>
-        {props.tabs.map((tab) => (
-          <TabsTrigger key={tab.value} value={tab.value}>
-            {tab.label}
-          </TabsTrigger>
-        ))}
-      </TabsList>
-      {children}
-    </Tabs>
-  ),
-
-  TabContent: ({ props, children }) => (
-    <TabsContent value={props.value}>{children}</TabsContent>
-  ),
-
-  Textarea: ({ props }) => {
-    const { data, set } = useData();
-    return (
-      <div className="flex flex-col gap-2">
-        {props.label ? <Label>{props.label}</Label> : null}
-        <Textarea
-          value={(getByPath(data, props.valuePath) as string) ?? ""}
-          placeholder={props.placeholder ?? ""}
-          rows={props.rows ?? 3}
-          onChange={(e) => set(props.valuePath, e.target.value)}
-        />
-      </div>
-    );
-  },
-
-  Tooltip: ({ props, children }) => (
-    <TooltipProvider>
-      <Tooltip>
-        <TooltipTrigger asChild>{children}</TooltipTrigger>
-        <TooltipContent>{props.content}</TooltipContent>
-      </Tooltip>
-    </TooltipProvider>
-  ),
-
-  // Heading is intentionally not rendered - widgets already have a title bar
-  Heading: () => null,
-
-  Text: ({ props }) => (
-    <p className={props.muted ? "text-muted-foreground" : ""}>
-      {props.content}
-    </p>
-  ),
-
-  Table: ({ props, onAction }) => {
-    const { data } = useData();
-    const path = props.dataPath.replace(/\./g, "/");
-    const rawData = getByPath(data, path);
-
-    const items: Array<Record<string, unknown>> = Array.isArray(rawData)
-      ? rawData
-      : Array.isArray((rawData as Record<string, unknown>)?.data)
-        ? ((rawData as Record<string, unknown>).data as Array<
-            Record<string, unknown>
-          >)
-        : Array.isArray((rawData as Record<string, unknown>)?.items)
-          ? ((rawData as Record<string, unknown>).items as Array<
-              Record<string, unknown>
-            >)
-          : [];
-
-    if (items.length === 0) {
-      return (
-        <div className="text-center py-4 text-muted-foreground">
-          {props.emptyMessage ?? "No data"}
-        </div>
-      );
-    }
-
-    const hasRowActions = props.rowActions && props.rowActions.length > 0;
-
-    return (
-      <Table>
-        <TableHeader>
-          <TableRow>
-            {props.columns.map((col) => (
-              <TableHead key={col.key}>{col.label}</TableHead>
-            ))}
-            {hasRowActions ? <TableHead>Actions</TableHead> : null}
-          </TableRow>
-        </TableHeader>
-        <TableBody>
-          {items.map((item, i) => (
-            <TableRow key={i}>
-              {props.columns.map((col) => (
-                <TableCell key={col.key}>
-                  {String(item[col.key] ?? "")}
-                </TableCell>
-              ))}
-              {hasRowActions ? (
-                <TableCell>
-                  <div className="flex gap-1">
-                    {props.rowActions!.map((rowAction) => (
-                      <Button
-                        key={rowAction.action}
-                        variant={rowAction.variant ?? "ghost"}
-                        size="sm"
-                        onClick={() =>
-                          onAction?.({
-                            name: rowAction.action,
-                            params: { id: item.id as string },
-                          })
-                        }
-                      >
-                        {rowAction.label}
-                      </Button>
-                    ))}
-                  </div>
-                </TableCell>
-              ) : null}
-            </TableRow>
-          ))}
-        </TableBody>
-      </Table>
-    );
-  },
-
-  BarChart: ({ props }) => {
-    const { data } = useData();
-    const path = props.dataPath.replace(/\./g, "/");
-    const rawData = getByPath(data, path);
-
-    const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
-      ? rawData
-      : Array.isArray((rawData as Record<string, unknown>)?.data)
-        ? ((rawData as Record<string, unknown>).data as Array<
-            Record<string, unknown>
-          >)
-        : [];
-
-    // Process and aggregate data
-    const { items, valueKey } = processChartData(
-      rawItems,
-      props.xKey,
-      props.yKey,
-      props.aggregate,
-    );
-
-    const chartColor = props.color ?? "var(--chart-1)";
-
-    const chartConfig = {
-      [valueKey]: {
-        label: valueKey,
-        color: chartColor,
-      },
-    } satisfies ChartConfig;
-
-    if (items.length === 0) {
-      return (
-        <div className="w-full">
-          <div className="text-center py-4 text-muted-foreground">
-            No data available
-          </div>
-        </div>
-      );
-    }
-
-    return (
-      <div className="w-full">
-        <ChartContainer
-          config={chartConfig}
-          className="min-h-[200px] w-full"
-          style={{ height: props.height ?? 300 }}
-        >
-          <RechartsBarChart accessibilityLayer data={items}>
-            <CartesianGrid vertical={false} />
-            <XAxis
-              dataKey="label"
-              tickLine={false}
-              tickMargin={10}
-              axisLine={false}
-            />
-            <ChartTooltip content={<ChartTooltipContent />} />
-            <Bar
-              dataKey={valueKey}
-              fill={`var(--color-${valueKey})`}
-              radius={4}
-            />
-          </RechartsBarChart>
-        </ChartContainer>
-      </div>
-    );
-  },
-
-  LineChart: ({ props }) => {
-    const { data } = useData();
-    const path = props.dataPath.replace(/\./g, "/");
-    const rawData = getByPath(data, path);
-
-    const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
-      ? rawData
-      : Array.isArray((rawData as Record<string, unknown>)?.data)
-        ? ((rawData as Record<string, unknown>).data as Array<
-            Record<string, unknown>
-          >)
-        : [];
-
-    // Process and aggregate data
-    const { items, valueKey } = processChartData(
-      rawItems,
-      props.xKey,
-      props.yKey,
-      props.aggregate,
-    );
-
-    const chartColor = props.color ?? "var(--chart-1)";
-
-    const chartConfig = {
-      [valueKey]: {
-        label: valueKey,
-        color: chartColor,
-      },
-    } satisfies ChartConfig;
-
-    if (items.length === 0) {
-      return (
-        <div className="w-full">
-          <div className="text-center py-4 text-muted-foreground">
-            No data available
-          </div>
-        </div>
-      );
-    }
-
-    return (
-      <div className="w-full">
-        <ChartContainer
-          config={chartConfig}
-          className="min-h-[200px] w-full"
-          style={{ height: props.height ?? 300 }}
-        >
-          <RechartsLineChart accessibilityLayer data={items}>
-            <CartesianGrid vertical={false} />
-            <XAxis
-              dataKey="label"
-              tickLine={false}
-              tickMargin={10}
-              axisLine={false}
-            />
-            <ChartTooltip content={<ChartTooltipContent />} />
-            <Line
-              type="monotone"
-              dataKey={valueKey}
-              stroke={`var(--color-${valueKey})`}
-              strokeWidth={2}
-              dot={false}
-            />
-          </RechartsLineChart>
-        </ChartContainer>
-      </div>
-    );
-  },
-};
-
-// =============================================================================
-// Chart Helpers
-// =============================================================================
-
-function isISODate(value: unknown): boolean {
-  if (typeof value !== "string") return false;
-  // Check for ISO date format: YYYY-MM-DDTHH:mm:ss
-  return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
-}
-
-function formatDateLabel(value: string): string {
-  const date = new Date(value);
-  if (isNaN(date.getTime())) return value;
-  // Format as "Mon DD" (e.g., "Feb 04")
-  return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
-}
-
-function processChartData(
-  items: Array<Record<string, unknown>>,
-  xKey: string,
-  yKey: string,
-  aggregate: "sum" | "count" | "avg" | null | undefined,
-): { items: Array<Record<string, unknown>>; valueKey: string } {
-  if (items.length === 0) {
-    return { items: [], valueKey: yKey };
-  }
-
-  // Check if xKey contains date values
-  const firstXValue = items[0]?.[xKey];
-  const isDateKey = isISODate(firstXValue);
-
-  // If no aggregation, just format dates and return
-  if (!aggregate) {
-    const formatted = items.map((item) => {
-      const xValue = item[xKey];
-      return {
-        ...item,
-        label:
-          isDateKey && typeof xValue === "string"
-            ? formatDateLabel(xValue)
-            : String(xValue ?? ""),
-      };
-    });
-    return { items: formatted, valueKey: yKey };
-  }
-
-  // Group items by xKey (use date string for dates)
-  const groups = new Map<string, Array<Record<string, unknown>>>();
-
-  for (const item of items) {
-    const xValue = item[xKey];
-    let groupKey: string;
-
-    if (isDateKey && typeof xValue === "string") {
-      // Group by date (YYYY-MM-DD)
-      groupKey = xValue.split("T")[0] ?? xValue;
-    } else {
-      groupKey = String(xValue ?? "unknown");
-    }
-
-    const group = groups.get(groupKey) ?? [];
-    group.push(item);
-    groups.set(groupKey, group);
-  }
-
-  // Aggregate each group
-  const valueKey = aggregate === "count" ? "count" : yKey;
-  const aggregated: Array<Record<string, unknown>> = [];
-
-  // Sort keys (for dates, this will be chronological)
-  const sortedKeys = Array.from(groups.keys()).sort();
-
-  for (const key of sortedKeys) {
-    const group = groups.get(key)!;
-    let value: number;
-
-    if (aggregate === "count") {
-      value = group.length;
-    } else if (aggregate === "sum") {
-      value = group.reduce((sum, item) => {
-        const v = item[yKey];
-        return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
-      }, 0);
-    } else {
-      // avg
-      const sum = group.reduce((s, item) => {
-        const v = item[yKey];
-        return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
-      }, 0);
-      value = group.length > 0 ? sum / group.length : 0;
-    }
-
-    // Format label
-    let label: string;
-    if (isDateKey) {
-      const date = new Date(key);
-      label = date.toLocaleDateString("en-US", {
-        month: "short",
-        day: "2-digit",
-      });
-    } else {
-      label = key;
-    }
-
-    aggregated.push({
-      label,
-      [valueKey]: value,
-      _groupKey: key,
-    });
-  }
-
-  return { items: aggregated, valueKey };
-}
-
-// =============================================================================
-// Fallback Component
-// =============================================================================
-
-export function Fallback({ type }: { type: string }) {
-  return (
-    <div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
-      Unknown component: {type}
-    </div>
-  );
-}

+ 998 - 0
examples/dashboard/lib/render/registry.tsx

@@ -0,0 +1,998 @@
+"use client";
+
+import { toast } from "sonner";
+import { findFormValue, getByPath } from "@json-render/core";
+import { useData, defineRegistry } from "@json-render/react";
+import {
+  Bar,
+  BarChart as RechartsBarChart,
+  CartesianGrid,
+  Line,
+  LineChart as RechartsLineChart,
+  XAxis,
+} from "recharts";
+import {
+  ChartContainer,
+  ChartTooltip,
+  ChartTooltipContent,
+  type ChartConfig,
+} from "@/components/ui/chart";
+
+// shadcn components
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+import { Separator } from "@/components/ui/separator";
+import {
+  Accordion,
+  AccordionItem,
+  AccordionTrigger,
+  AccordionContent,
+} from "@/components/ui/accordion";
+import {
+  Table,
+  TableHeader,
+  TableBody,
+  TableHead,
+  TableRow,
+  TableCell,
+} from "@/components/ui/table";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+  Dialog,
+  DialogTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogDescription,
+} from "@/components/ui/dialog";
+import {
+  Drawer,
+  DrawerTrigger,
+  DrawerContent,
+  DrawerHeader,
+  DrawerTitle,
+  DrawerDescription,
+} from "@/components/ui/drawer";
+import {
+  DropdownMenu,
+  DropdownMenuTrigger,
+  DropdownMenuContent,
+  DropdownMenuItem,
+} from "@/components/ui/dropdown-menu";
+import {
+  Pagination,
+  PaginationContent,
+  PaginationItem,
+  PaginationPrevious,
+  PaginationNext,
+  PaginationLink,
+} from "@/components/ui/pagination";
+import {
+  Popover,
+  PopoverTrigger,
+  PopoverContent,
+} from "@/components/ui/popover";
+import { Progress } from "@/components/ui/progress";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+  Select,
+  SelectTrigger,
+  SelectValue,
+  SelectContent,
+  SelectItem,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import {
+  Tooltip,
+  TooltipTrigger,
+  TooltipContent,
+  TooltipProvider,
+} from "@/components/ui/tooltip";
+
+import { dashboardCatalog } from "./catalog";
+
+// =============================================================================
+// Registry
+// =============================================================================
+
+export const { registry, handlers, executeAction } = defineRegistry(
+  dashboardCatalog,
+  {
+    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"} ${gapClass}`}
+          >
+            {children}
+          </div>
+        );
+      },
+
+      Accordion: ({ props, children }) => (
+        <Accordion type={props.type ?? "single"} collapsible>
+          {children}
+        </Accordion>
+      ),
+
+      AccordionItem: ({ props, children }) => (
+        <AccordionItem value={props.value}>
+          <AccordionTrigger>{props.title}</AccordionTrigger>
+          <AccordionContent>{children}</AccordionContent>
+        </AccordionItem>
+      ),
+
+      Button: ({ props, onAction, loading }) => (
+        <Button
+          variant={props.variant ?? "default"}
+          disabled={loading || (props.disabled ?? false)}
+          onClick={() =>
+            onAction?.({
+              name: props.action,
+              params: props.actionParams ?? undefined,
+            })
+          }
+        >
+          {loading ? "..." : props.label}
+        </Button>
+      ),
+
+      Input: ({ props }) => {
+        const { data, set } = useData();
+        return (
+          <div className="flex flex-col gap-2">
+            {props.label ? <Label>{props.label}</Label> : null}
+            <Input
+              type={props.type ?? "text"}
+              value={(getByPath(data, props.valuePath) as string) ?? ""}
+              placeholder={props.placeholder ?? ""}
+              onChange={(e) => set(props.valuePath, e.target.value)}
+            />
+          </div>
+        );
+      },
+
+      Form: ({ props, children, onAction }) => (
+        <form
+          onSubmit={(e) => {
+            e.preventDefault();
+            onAction?.({
+              name: props.submitAction,
+              params: props.submitActionParams ?? undefined,
+            });
+          }}
+          className="flex flex-col gap-4"
+        >
+          {children}
+        </form>
+      ),
+
+      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 />,
+
+      Avatar: ({ props }) => (
+        <Avatar>
+          {props.src ? (
+            <AvatarImage src={props.src} alt={props.alt ?? ""} />
+          ) : null}
+          <AvatarFallback>{props.fallback}</AvatarFallback>
+        </Avatar>
+      ),
+
+      Checkbox: ({ props }) => {
+        const { data, set } = useData();
+        const checked =
+          (getByPath(data, props.valuePath) as boolean) ??
+          props.defaultChecked ??
+          false;
+        return (
+          <div className="flex items-center gap-2">
+            <Checkbox
+              id={props.valuePath}
+              checked={checked}
+              onCheckedChange={(value) => set(props.valuePath, value)}
+            />
+            {props.label ? (
+              <Label htmlFor={props.valuePath}>{props.label}</Label>
+            ) : null}
+          </div>
+        );
+      },
+
+      Dialog: ({ props, children }) => (
+        <Dialog>
+          <DialogTrigger asChild>
+            <Button variant="outline">{props.trigger}</Button>
+          </DialogTrigger>
+          <DialogContent>
+            <DialogHeader>
+              <DialogTitle>{props.title}</DialogTitle>
+              {props.description ? (
+                <DialogDescription>{props.description}</DialogDescription>
+              ) : null}
+            </DialogHeader>
+            {children}
+          </DialogContent>
+        </Dialog>
+      ),
+
+      Drawer: ({ props, children }) => (
+        <Drawer>
+          <DrawerTrigger asChild>
+            <Button variant="outline">{props.trigger}</Button>
+          </DrawerTrigger>
+          <DrawerContent>
+            <DrawerHeader>
+              <DrawerTitle>{props.title}</DrawerTitle>
+              {props.description ? (
+                <DrawerDescription>{props.description}</DrawerDescription>
+              ) : null}
+            </DrawerHeader>
+            <div className="p-4">{children}</div>
+          </DrawerContent>
+        </Drawer>
+      ),
+
+      DropdownMenu: ({ props, onAction }) => (
+        <DropdownMenu>
+          <DropdownMenuTrigger asChild>
+            <Button variant="outline">{props.trigger}</Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent>
+            {props.items.map((item, i) => (
+              <DropdownMenuItem
+                key={i}
+                onClick={() =>
+                  item.action
+                    ? onAction?.({
+                        name: item.action,
+                        params: item.actionParams ?? undefined,
+                      })
+                    : undefined
+                }
+              >
+                {item.label}
+              </DropdownMenuItem>
+            ))}
+          </DropdownMenuContent>
+        </DropdownMenu>
+      ),
+
+      Label: ({ props }) => (
+        <Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
+      ),
+
+      Pagination: ({ props }) => {
+        const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
+        return (
+          <Pagination>
+            <PaginationContent>
+              <PaginationItem>
+                <PaginationPrevious href="#" />
+              </PaginationItem>
+              {pages.map((page) => (
+                <PaginationItem key={page}>
+                  <PaginationLink
+                    href="#"
+                    isActive={page === props.currentPage}
+                  >
+                    {page}
+                  </PaginationLink>
+                </PaginationItem>
+              ))}
+              <PaginationItem>
+                <PaginationNext href="#" />
+              </PaginationItem>
+            </PaginationContent>
+          </Pagination>
+        );
+      },
+
+      Popover: ({ props, children }) => (
+        <Popover>
+          <PopoverTrigger asChild>
+            <Button variant="outline">{props.trigger}</Button>
+          </PopoverTrigger>
+          <PopoverContent>{children}</PopoverContent>
+        </Popover>
+      ),
+
+      Progress: ({ props }) => (
+        <Progress value={props.value} max={props.max ?? 100} />
+      ),
+
+      RadioGroup: ({ props }) => {
+        const { data, set } = useData();
+        const value =
+          (getByPath(data, props.valuePath) as string) ??
+          props.defaultValue ??
+          "";
+        return (
+          <RadioGroup
+            value={value}
+            onValueChange={(v) => set(props.valuePath, v)}
+          >
+            {props.options.map((option) => (
+              <div key={option.value} className="flex items-center gap-2">
+                <RadioGroupItem value={option.value} id={option.value} />
+                <Label htmlFor={option.value}>{option.label}</Label>
+              </div>
+            ))}
+          </RadioGroup>
+        );
+      },
+
+      Select: ({ props }) => {
+        const { data, set } = useData();
+        const value = (getByPath(data, props.valuePath) as string) ?? "";
+        return (
+          <Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
+            <SelectTrigger>
+              <SelectValue placeholder={props.placeholder ?? "Select..."} />
+            </SelectTrigger>
+            <SelectContent>
+              {props.options.map((option) => (
+                <SelectItem key={option.value} value={option.value}>
+                  {option.label}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        );
+      },
+
+      Skeleton: ({ props }) => (
+        <Skeleton
+          className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
+        />
+      ),
+
+      Spinner: ({ props }) => {
+        const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
+        const size = sizes[props.size ?? "md"];
+        return (
+          <div
+            className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
+          />
+        );
+      },
+
+      Switch: ({ props }) => {
+        const { data, set } = useData();
+        const checked =
+          (getByPath(data, props.valuePath) as boolean) ??
+          props.defaultChecked ??
+          false;
+        return (
+          <div className="flex items-center gap-2">
+            <Switch
+              id={props.valuePath}
+              checked={checked}
+              onCheckedChange={(value) => set(props.valuePath, value)}
+            />
+            {props.label ? (
+              <Label htmlFor={props.valuePath}>{props.label}</Label>
+            ) : null}
+          </div>
+        );
+      },
+
+      Tabs: ({ props, children }) => (
+        <Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
+          <TabsList>
+            {props.tabs.map((tab) => (
+              <TabsTrigger key={tab.value} value={tab.value}>
+                {tab.label}
+              </TabsTrigger>
+            ))}
+          </TabsList>
+          {children}
+        </Tabs>
+      ),
+
+      TabContent: ({ props, children }) => (
+        <TabsContent value={props.value}>{children}</TabsContent>
+      ),
+
+      Textarea: ({ props }) => {
+        const { data, set } = useData();
+        return (
+          <div className="flex flex-col gap-2">
+            {props.label ? <Label>{props.label}</Label> : null}
+            <Textarea
+              value={(getByPath(data, props.valuePath) as string) ?? ""}
+              placeholder={props.placeholder ?? ""}
+              rows={props.rows ?? 3}
+              onChange={(e) => set(props.valuePath, e.target.value)}
+            />
+          </div>
+        );
+      },
+
+      Tooltip: ({ props, children }) => (
+        <TooltipProvider>
+          <Tooltip>
+            <TooltipTrigger asChild>{children}</TooltipTrigger>
+            <TooltipContent>{props.content}</TooltipContent>
+          </Tooltip>
+        </TooltipProvider>
+      ),
+
+      // Heading is intentionally not rendered - widgets already have a title bar
+      Heading: () => null,
+
+      Text: ({ props }) => (
+        <p className={props.muted ? "text-muted-foreground" : ""}>
+          {props.content}
+        </p>
+      ),
+
+      Table: ({ props, onAction }) => {
+        const { data } = useData();
+        const path = props.dataPath.replace(/\./g, "/");
+        const rawData = getByPath(data, path);
+
+        const items: Array<Record<string, unknown>> = Array.isArray(rawData)
+          ? rawData
+          : Array.isArray((rawData as Record<string, unknown>)?.data)
+            ? ((rawData as Record<string, unknown>).data as Array<
+                Record<string, unknown>
+              >)
+            : Array.isArray((rawData as Record<string, unknown>)?.items)
+              ? ((rawData as Record<string, unknown>).items as Array<
+                  Record<string, unknown>
+                >)
+              : [];
+
+        if (items.length === 0) {
+          return (
+            <div className="text-center py-4 text-muted-foreground">
+              {props.emptyMessage ?? "No data"}
+            </div>
+          );
+        }
+
+        const hasRowActions = props.rowActions && props.rowActions.length > 0;
+
+        return (
+          <Table>
+            <TableHeader>
+              <TableRow>
+                {props.columns.map((col) => (
+                  <TableHead key={col.key}>{col.label}</TableHead>
+                ))}
+                {hasRowActions ? <TableHead>Actions</TableHead> : null}
+              </TableRow>
+            </TableHeader>
+            <TableBody>
+              {items.map((item, i) => (
+                <TableRow key={i}>
+                  {props.columns.map((col) => (
+                    <TableCell key={col.key}>
+                      {String(item[col.key] ?? "")}
+                    </TableCell>
+                  ))}
+                  {hasRowActions ? (
+                    <TableCell>
+                      <div className="flex gap-1">
+                        {props.rowActions!.map((rowAction) => (
+                          <Button
+                            key={rowAction.action}
+                            variant={rowAction.variant ?? "ghost"}
+                            size="sm"
+                            onClick={() =>
+                              onAction?.({
+                                name: rowAction.action,
+                                params: { id: item.id as string },
+                              })
+                            }
+                          >
+                            {rowAction.label}
+                          </Button>
+                        ))}
+                      </div>
+                    </TableCell>
+                  ) : null}
+                </TableRow>
+              ))}
+            </TableBody>
+          </Table>
+        );
+      },
+
+      BarChart: ({ props }) => {
+        const { data } = useData();
+        const path = props.dataPath.replace(/\./g, "/");
+        const rawData = getByPath(data, path);
+
+        const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
+          ? rawData
+          : Array.isArray((rawData as Record<string, unknown>)?.data)
+            ? ((rawData as Record<string, unknown>).data as Array<
+                Record<string, unknown>
+              >)
+            : [];
+
+        // Process and aggregate data
+        const { items, valueKey } = processChartData(
+          rawItems,
+          props.xKey,
+          props.yKey,
+          props.aggregate,
+        );
+
+        const chartColor = props.color ?? "var(--chart-1)";
+
+        const chartConfig = {
+          [valueKey]: {
+            label: valueKey,
+            color: chartColor,
+          },
+        } satisfies ChartConfig;
+
+        if (items.length === 0) {
+          return (
+            <div className="w-full">
+              <div className="text-center py-4 text-muted-foreground">
+                No data available
+              </div>
+            </div>
+          );
+        }
+
+        return (
+          <div className="w-full">
+            <ChartContainer
+              config={chartConfig}
+              className="min-h-[200px] w-full"
+              style={{ height: props.height ?? 300 }}
+            >
+              <RechartsBarChart accessibilityLayer data={items}>
+                <CartesianGrid vertical={false} />
+                <XAxis
+                  dataKey="label"
+                  tickLine={false}
+                  tickMargin={10}
+                  axisLine={false}
+                />
+                <ChartTooltip content={<ChartTooltipContent />} />
+                <Bar
+                  dataKey={valueKey}
+                  fill={`var(--color-${valueKey})`}
+                  radius={4}
+                />
+              </RechartsBarChart>
+            </ChartContainer>
+          </div>
+        );
+      },
+
+      LineChart: ({ props }) => {
+        const { data } = useData();
+        const path = props.dataPath.replace(/\./g, "/");
+        const rawData = getByPath(data, path);
+
+        const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
+          ? rawData
+          : Array.isArray((rawData as Record<string, unknown>)?.data)
+            ? ((rawData as Record<string, unknown>).data as Array<
+                Record<string, unknown>
+              >)
+            : [];
+
+        // Process and aggregate data
+        const { items, valueKey } = processChartData(
+          rawItems,
+          props.xKey,
+          props.yKey,
+          props.aggregate,
+        );
+
+        const chartColor = props.color ?? "var(--chart-1)";
+
+        const chartConfig = {
+          [valueKey]: {
+            label: valueKey,
+            color: chartColor,
+          },
+        } satisfies ChartConfig;
+
+        if (items.length === 0) {
+          return (
+            <div className="w-full">
+              <div className="text-center py-4 text-muted-foreground">
+                No data available
+              </div>
+            </div>
+          );
+        }
+
+        return (
+          <div className="w-full">
+            <ChartContainer
+              config={chartConfig}
+              className="min-h-[200px] w-full"
+              style={{ height: props.height ?? 300 }}
+            >
+              <RechartsLineChart accessibilityLayer data={items}>
+                <CartesianGrid vertical={false} />
+                <XAxis
+                  dataKey="label"
+                  tickLine={false}
+                  tickMargin={10}
+                  axisLine={false}
+                />
+                <ChartTooltip content={<ChartTooltipContent />} />
+                <Line
+                  type="monotone"
+                  dataKey={valueKey}
+                  stroke={`var(--color-${valueKey})`}
+                  strokeWidth={2}
+                  dot={false}
+                />
+              </RechartsLineChart>
+            </ChartContainer>
+          </div>
+        );
+      },
+    },
+
+    actions: {
+      viewCustomers: async (params, setData) => {
+        const queryParams = new URLSearchParams();
+        if (params?.limit) queryParams.set("limit", String(params.limit));
+        if (params?.sort) queryParams.set("sort", String(params.sort));
+        if (params?.status) queryParams.set("status", String(params.status));
+        const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
+        const res = await fetch(url);
+        const customers = await res.json();
+        setData((prev) => ({ ...prev, customers }));
+      },
+
+      refreshCustomers: async (params, setData) => {
+        const queryParams = new URLSearchParams();
+        if (params?.limit) queryParams.set("limit", String(params.limit));
+        if (params?.sort) queryParams.set("sort", String(params.sort));
+        const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
+        const res = await fetch(url);
+        const customers = await res.json();
+        setData((prev) => ({ ...prev, customers }));
+      },
+
+      createCustomer: async (params, setData, data) => {
+        const name = findFormValue("name", params, data) as string;
+        const email =
+          (findFormValue("email", params, data) as string) ||
+          `${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
+        const phone = findFormValue("phone", params, data) as
+          | string
+          | undefined;
+
+        if (!name) {
+          toast.error("Customer name is required");
+          return;
+        }
+
+        try {
+          const res = await fetch("/api/v1/customers", {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({ name, email, phone }),
+          });
+          const customer = await res.json();
+          if (res.ok) {
+            toast.success(`Customer "${customer.name}" created`);
+            const listRes = await fetch("/api/v1/customers");
+            const customers = await listRes.json();
+            setData((prev) => ({ ...prev, customers }));
+          } else {
+            toast.error(customer.error || "Failed to create customer");
+          }
+        } catch (err) {
+          console.error("Failed to create customer:", err);
+          toast.error("Failed to create customer");
+        }
+      },
+
+      deleteCustomer: async (params, setData, data) => {
+        const customerId =
+          findFormValue("customerId", params, data) ||
+          findFormValue("id", params, data);
+        if (!customerId) {
+          toast.error("Customer ID required");
+          return;
+        }
+        try {
+          const res = await fetch(`/api/v1/customers/${customerId}`, {
+            method: "DELETE",
+          });
+          if (res.ok) {
+            toast.success("Customer deleted");
+            const listRes = await fetch("/api/v1/customers");
+            const customers = await listRes.json();
+            setData((prev) => ({ ...prev, customers }));
+          } else {
+            const err = await res.json();
+            toast.error(err.error || "Failed to delete customer");
+          }
+        } catch (err) {
+          console.error("Failed to delete customer:", err);
+          toast.error("Failed to delete customer");
+        }
+      },
+
+      viewInvoices: async (params, setData) => {
+        const queryParams = new URLSearchParams();
+        if (params?.status) queryParams.set("status", String(params.status));
+        const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
+        const res = await fetch(url);
+        const invoices = await res.json();
+        setData((prev) => ({ ...prev, invoices }));
+      },
+
+      refreshInvoices: async (_params, setData) => {
+        const res = await fetch("/api/v1/invoices");
+        const invoices = await res.json();
+        setData((prev) => ({ ...prev, invoices }));
+      },
+
+      createInvoice: async (params, setData) => {
+        if (!params?.customerId || !params?.dueDate) {
+          toast.error("Customer ID and due date required");
+          return;
+        }
+        try {
+          const res = await fetch("/api/v1/invoices", {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify(params),
+          });
+          const invoice = await res.json();
+          if (res.ok) {
+            toast.success("Invoice created");
+            const listRes = await fetch("/api/v1/invoices");
+            const invoices = await listRes.json();
+            setData((prev) => ({ ...prev, invoices }));
+          } else {
+            toast.error(invoice.error || "Failed to create invoice");
+          }
+        } catch (err) {
+          console.error("Failed to create invoice:", err);
+          toast.error("Failed to create invoice");
+        }
+      },
+
+      sendInvoice: async (params) => {
+        if (!params?.invoiceId) {
+          toast.error("Invoice ID required");
+          return;
+        }
+        const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
+          method: "POST",
+        });
+        const result = await res.json();
+        if (res.ok) {
+          toast.success(result.message || "Invoice sent");
+        } else {
+          toast.error(result.error || "Failed to send invoice");
+        }
+      },
+
+      markInvoicePaid: async (params) => {
+        if (!params?.invoiceId) {
+          toast.error("Invoice ID required");
+          return;
+        }
+        const res = await fetch(
+          `/api/v1/invoices/${params.invoiceId}/mark-paid`,
+          { method: "POST" },
+        );
+        const result = await res.json();
+        if (res.ok) {
+          toast.success(result.message || "Invoice marked paid");
+        } else {
+          toast.error(result.error || "Failed to mark invoice paid");
+        }
+      },
+
+      viewExpenses: async (params, setData) => {
+        const queryParams = new URLSearchParams();
+        if (params?.status) queryParams.set("status", String(params.status));
+        const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
+        const res = await fetch(url);
+        const expenses = await res.json();
+        setData((prev) => ({ ...prev, expenses }));
+      },
+
+      refreshExpenses: async (_params, setData) => {
+        const res = await fetch("/api/v1/expenses");
+        const expenses = await res.json();
+        setData((prev) => ({ ...prev, expenses }));
+      },
+
+      createExpense: async (params, setData) => {
+        if (
+          !params?.vendor ||
+          !params?.category ||
+          params?.amount === undefined
+        ) {
+          toast.error("Vendor, category, and amount required");
+          return;
+        }
+        try {
+          const res = await fetch("/api/v1/expenses", {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify(params),
+          });
+          const expense = await res.json();
+          if (res.ok) {
+            toast.success("Expense created");
+            const listRes = await fetch("/api/v1/expenses");
+            const expenses = await listRes.json();
+            setData((prev) => ({ ...prev, expenses }));
+          } else {
+            toast.error(expense.error || "Failed to create expense");
+          }
+        } catch (err) {
+          console.error("Failed to create expense:", err);
+          toast.error("Failed to create expense");
+        }
+      },
+
+      approveExpense: async (params) => {
+        if (!params?.expenseId) {
+          toast.error("Expense ID required");
+          return;
+        }
+        const res = await fetch(
+          `/api/v1/expenses/${params.expenseId}/approve`,
+          { method: "POST" },
+        );
+        const result = await res.json();
+        if (res.ok) {
+          toast.success(result.message || "Expense approved");
+        } else {
+          toast.error(result.error || "Failed to approve expense");
+        }
+      },
+    },
+  },
+);
+
+// =============================================================================
+// Chart Helpers
+// =============================================================================
+
+function isISODate(value: unknown): boolean {
+  if (typeof value !== "string") return false;
+  return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
+}
+
+function formatDateLabel(value: string): string {
+  const date = new Date(value);
+  if (isNaN(date.getTime())) return value;
+  return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
+}
+
+function processChartData(
+  items: Array<Record<string, unknown>>,
+  xKey: string,
+  yKey: string,
+  aggregate: "sum" | "count" | "avg" | null | undefined,
+): { items: Array<Record<string, unknown>>; valueKey: string } {
+  if (items.length === 0) {
+    return { items: [], valueKey: yKey };
+  }
+
+  const firstXValue = items[0]?.[xKey];
+  const isDateKey = isISODate(firstXValue);
+
+  if (!aggregate) {
+    const formatted = items.map((item) => {
+      const xValue = item[xKey];
+      return {
+        ...item,
+        label:
+          isDateKey && typeof xValue === "string"
+            ? formatDateLabel(xValue)
+            : String(xValue ?? ""),
+      };
+    });
+    return { items: formatted, valueKey: yKey };
+  }
+
+  const groups = new Map<string, Array<Record<string, unknown>>>();
+
+  for (const item of items) {
+    const xValue = item[xKey];
+    let groupKey: string;
+
+    if (isDateKey && typeof xValue === "string") {
+      groupKey = xValue.split("T")[0] ?? xValue;
+    } else {
+      groupKey = String(xValue ?? "unknown");
+    }
+
+    const group = groups.get(groupKey) ?? [];
+    group.push(item);
+    groups.set(groupKey, group);
+  }
+
+  const valueKey = aggregate === "count" ? "count" : yKey;
+  const aggregated: Array<Record<string, unknown>> = [];
+  const sortedKeys = Array.from(groups.keys()).sort();
+
+  for (const key of sortedKeys) {
+    const group = groups.get(key)!;
+    let value: number;
+
+    if (aggregate === "count") {
+      value = group.length;
+    } else if (aggregate === "sum") {
+      value = group.reduce((sum, item) => {
+        const v = item[yKey];
+        return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
+      }, 0);
+    } else {
+      const sum = group.reduce((s, item) => {
+        const v = item[yKey];
+        return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
+      }, 0);
+      value = group.length > 0 ? sum / group.length : 0;
+    }
+
+    let label: string;
+    if (isDateKey) {
+      const date = new Date(key);
+      label = date.toLocaleDateString("en-US", {
+        month: "short",
+        day: "2-digit",
+      });
+    } else {
+      label = key;
+    }
+
+    aggregated.push({
+      label,
+      [valueKey]: value,
+      _groupKey: key,
+    });
+  }
+
+  return { items: aggregated, valueKey };
+}
+
+// =============================================================================
+// Fallback Component
+// =============================================================================
+
+export function Fallback({ type }: { type: string }) {
+  return (
+    <div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
+      Unknown component: {type}
+    </div>
+  );
+}

+ 16 - 42
examples/dashboard/lib/render/renderer.tsx

@@ -3,15 +3,14 @@
 import { useMemo, useRef, type ReactNode } from "react";
 import {
   Renderer,
-  type ComponentRegistry,
+  type ComponentRenderer,
   type Spec,
   DataProvider,
   VisibilityProvider,
   ActionProvider,
 } from "@json-render/react";
 
-import { components, Fallback } from "./catalog/components";
-import { executeAction } from "./catalog/actions";
+import { registry, Fallback, handlers as createHandlers } from "./registry";
 
 // =============================================================================
 // DashboardRenderer
@@ -29,39 +28,9 @@ interface DashboardRendererProps {
   loading?: boolean;
 }
 
-// Build registry - uses refs to avoid recreating on data changes
-function buildRegistry(
-  dataRef: React.RefObject<Record<string, unknown>>,
-  setDataRef: React.RefObject<SetData | undefined>,
-  loading?: boolean,
-): ComponentRegistry {
-  const registry: ComponentRegistry = {};
-
-  for (const [name, componentFn] of Object.entries(components)) {
-    registry[name] = (renderProps: {
-      element: { props: Record<string, unknown> };
-      children?: ReactNode;
-    }) =>
-      componentFn({
-        props: renderProps.element.props as never,
-        children: renderProps.children,
-        onAction: (a) => {
-          const setData = setDataRef.current;
-          const data = dataRef.current;
-          if (setData) {
-            executeAction(a.name, a.params, setData, data);
-          }
-        },
-        loading,
-      });
-  }
-
-  return registry;
-}
-
 // Fallback component for unknown types
-const fallbackRegistry = (renderProps: { element: { type: string } }) => (
-  <Fallback type={renderProps.element.type} />
+const fallback: ComponentRenderer = ({ element }) => (
+  <Fallback type={element.type} />
 );
 
 export function DashboardRenderer({
@@ -71,16 +40,21 @@ export function DashboardRenderer({
   onDataChange,
   loading,
 }: DashboardRendererProps): ReactNode {
-  // Use refs to keep registry stable while still accessing latest data/setData
+  // Use refs so action handlers always see the latest data/setData
   const dataRef = useRef(data);
   const setDataRef = useRef(setData);
   dataRef.current = data;
   setDataRef.current = setData;
 
-  // Memoize registry - only changes when loading changes
-  const registry = useMemo(
-    () => buildRegistry(dataRef, setDataRef, loading),
-    [loading],
+  // Create ActionProvider-compatible handlers using getters so they
+  // always read the latest data/setData from refs
+  const actionHandlers = useMemo(
+    () =>
+      createHandlers(
+        () => setDataRef.current,
+        () => dataRef.current,
+      ),
+    [],
   );
 
   if (!spec) return null;
@@ -88,11 +62,11 @@ export function DashboardRenderer({
   return (
     <DataProvider initialData={data} onDataChange={onDataChange}>
       <VisibilityProvider>
-        <ActionProvider>
+        <ActionProvider handlers={actionHandlers}>
           <Renderer
             spec={spec}
             registry={registry}
-            fallback={fallbackRegistry}
+            fallback={fallback}
             loading={loading}
           />
         </ActionProvider>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
examples/remotion/tsconfig.tsbuildinfo


+ 45 - 56
packages/react/README.md

@@ -51,33 +51,36 @@ export const catalog = defineCatalog(schema, {
 ### 2. Define Component Implementations
 
 ```tsx
-import { defineComponents, useData } from "@json-render/react";
-
-export const components = defineComponents(catalog, {
-  Card: ({ props, children }) => (
-    <div className="card">
-      <h3>{props.title}</h3>
-      {props.description && <p>{props.description}</p>}
-      {children}
-    </div>
-  ),
-  Button: ({ props, onAction }) => (
-    <button onClick={() => onAction?.(props.action)}>
-      {props.label}
-    </button>
-  ),
-  Input: ({ props }) => {
-    const { get, set } = useData();
-    return (
-      <label>
+import { defineRegistry, useData } from "@json-render/react";
+import { catalog } from "./catalog";
+
+export const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => (
+      <div className="card">
+        <h3>{props.title}</h3>
+        {props.description && <p>{props.description}</p>}
+        {children}
+      </div>
+    ),
+    Button: ({ props, onAction }) => (
+      <button onClick={() => onAction?.({ name: props.action })}>
         {props.label}
-        <input
-          placeholder={props.placeholder ?? ""}
-          value={get("/form/value") ?? ""}
-          onChange={(e) => set("/form/value", e.target.value)}
-        />
-      </label>
-    );
+      </button>
+    ),
+    Input: ({ props }) => {
+      const { get, set } = useData();
+      return (
+        <label>
+          {props.label}
+          <input
+            placeholder={props.placeholder ?? ""}
+            value={get("/form/value") ?? ""}
+            onChange={(e) => set("/form/value", e.target.value)}
+          />
+        </label>
+      );
+    },
   },
 });
 ```
@@ -86,20 +89,15 @@ export const components = defineComponents(catalog, {
 
 ```tsx
 import { Renderer, DataProvider, ActionProvider } from "@json-render/react";
+import { registry } from "./registry";
 
 function App({ spec }) {
-  const handleAction = (action: string) => {
-    console.log("Action triggered:", action);
-  };
-
   return (
     <DataProvider initialData={{ form: { value: "" } }}>
-      <ActionProvider onAction={handleAction}>
-        <Renderer
-          spec={spec}
-          catalog={catalog}
-          components={components}
-        />
+      <ActionProvider handlers={{
+        submit: () => console.log("Submit"),
+      }}>
+        <Renderer spec={spec} registry={registry} />
       </ActionProvider>
     </DataProvider>
   );
@@ -253,13 +251,14 @@ const { errors, validate } = useFieldValidation("/form/email", {
 
 ## Component Props
 
-Components receive these props:
+When using `defineRegistry`, components receive these props:
 
 ```typescript
-interface ComponentProps<P> {
-  props: P;                    // Props from spec
+interface ComponentContext<P> {
+  props: P;                    // Typed props from the catalog
   children?: React.ReactNode;  // Rendered children
-  onAction?: (action: string) => void;
+  onAction?: (action: { name: string; params?: Record<string, unknown> }) => void;
+  loading?: boolean;           // Whether the parent is loading
 }
 ```
 
@@ -274,13 +273,7 @@ const systemPrompt = catalog.prompt();
 
 ```tsx
 import { defineCatalog } from "@json-render/core";
-import {
-  schema,
-  defineComponents,
-  Renderer,
-  DataProvider,
-  ActionProvider,
-} from "@json-render/react";
+import { schema, defineRegistry, Renderer } from "@json-render/react";
 import { z } from "zod";
 
 const catalog = defineCatalog(schema, {
@@ -293,8 +286,10 @@ const catalog = defineCatalog(schema, {
   actions: {},
 });
 
-const components = defineComponents(catalog, {
-  Greeting: ({ props }) => <h1>Hello, {props.name}!</h1>,
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Greeting: ({ props }) => <h1>Hello, {props.name}!</h1>,
+  },
 });
 
 const spec = {
@@ -305,12 +300,6 @@ const spec = {
 };
 
 function App() {
-  return (
-    <DataProvider initialData={{}}>
-      <ActionProvider onAction={() => {}}>
-        <Renderer spec={spec} catalog={catalog} components={components} />
-      </ActionProvider>
-    </DataProvider>
-  );
+  return <Renderer spec={spec} registry={registry} />;
 }
 ```

+ 5 - 2
packages/react/src/index.ts

@@ -64,11 +64,14 @@ export type {
 
 // Renderer
 export {
-  // New API
+  // Registry
+  defineRegistry,
+  type DefineRegistryResult,
+  // createRenderer (higher-level, includes providers)
   createRenderer,
   type CreateRendererProps,
   type ComponentMap,
-  // Legacy API
+  // Low-level
   Renderer,
   JSONUIProvider,
   createRendererFromCatalog,

+ 154 - 2
packages/react/src/renderer.tsx

@@ -5,11 +5,18 @@ import type {
   UIElement,
   Spec,
   Action,
-  Catalog as NewCatalog,
+  Catalog,
   SchemaDefinition,
   LegacyCatalog,
   ComponentDefinition,
 } from "@json-render/core";
+import type {
+  Components,
+  Actions,
+  ActionFn,
+  SetData,
+  DataModel,
+} from "./catalog-types";
 import { useIsVisible } from "./contexts/visibility";
 import { useActions } from "./contexts/actions";
 import { useData } from "./contexts/data";
@@ -232,6 +239,151 @@ export function createRendererFromCatalog<
   };
 }
 
+// ============================================================================
+// defineRegistry
+// ============================================================================
+
+/**
+ * Result returned by defineRegistry
+ */
+export interface DefineRegistryResult {
+  /** Component registry for `<Renderer registry={...} />` */
+  registry: ComponentRegistry;
+  /**
+   * Create ActionProvider-compatible handlers.
+   * Accepts getter functions so handlers always read the latest data/setData
+   * (e.g. from React refs).
+   */
+  handlers: (
+    getSetData: () => SetData | undefined,
+    getData: () => DataModel,
+  ) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
+  /**
+   * Execute an action by name imperatively
+   * (for use outside the React tree, e.g. initial data loading).
+   */
+  executeAction: (
+    actionName: string,
+    params: Record<string, unknown> | undefined,
+    setData: SetData,
+    data?: DataModel,
+  ) => Promise<void>;
+}
+
+/**
+ * Create a registry from a catalog with components and/or actions.
+ *
+ * @example
+ * ```tsx
+ * // Components only
+ * const { registry } = defineRegistry(catalog, {
+ *   components: {
+ *     Card: ({ props, children }) => (
+ *       <div className="card">{props.title}{children}</div>
+ *     ),
+ *   },
+ * });
+ *
+ * // Actions only
+ * const { handlers, executeAction } = defineRegistry(catalog, {
+ *   actions: {
+ *     viewCustomers: async (params, setData) => { ... },
+ *   },
+ * });
+ *
+ * // Both
+ * const { registry, handlers, executeAction } = defineRegistry(catalog, {
+ *   components: { ... },
+ *   actions: { ... },
+ * });
+ * ```
+ */
+export function defineRegistry<C extends Catalog>(
+  _catalog: C,
+  options: {
+    components?: Components<C>;
+    actions?: Actions<C>;
+  },
+): DefineRegistryResult {
+  // Build component registry
+  const registry: ComponentRegistry = {};
+  if (options.components) {
+    for (const [name, componentFn] of Object.entries(options.components)) {
+      registry[name] = ({
+        element,
+        children,
+        onAction,
+        loading,
+      }: ComponentRenderProps) => {
+        return (componentFn as DefineRegistryComponentFn)({
+          props: element.props,
+          children,
+          onAction,
+          loading,
+        });
+      };
+    }
+  }
+
+  // Build action helpers
+  const actionMap = options.actions
+    ? (Object.entries(options.actions) as Array<
+        [string, DefineRegistryActionFn]
+      >)
+    : [];
+
+  const handlers = (
+    getSetData: () => SetData | undefined,
+    getData: () => DataModel,
+  ): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
+    const result: Record<
+      string,
+      (params: Record<string, unknown>) => Promise<void>
+    > = {};
+    for (const [name, actionFn] of actionMap) {
+      result[name] = async (params) => {
+        const setData = getSetData();
+        const data = getData();
+        if (setData) {
+          await actionFn(params, setData, data);
+        }
+      };
+    }
+    return result;
+  };
+
+  const executeAction = async (
+    actionName: string,
+    params: Record<string, unknown> | undefined,
+    setData: SetData,
+    data: DataModel = {},
+  ): Promise<void> => {
+    const entry = actionMap.find(([name]) => name === actionName);
+    if (entry) {
+      await entry[1](params, setData, data);
+    } else {
+      console.warn(`Unknown action: ${actionName}`);
+    }
+  };
+
+  return { registry, handlers, executeAction };
+}
+
+/** @internal */
+type DefineRegistryComponentFn = (ctx: {
+  props: unknown;
+  children?: React.ReactNode;
+  onAction?: (action: Action) => void;
+  loading?: boolean;
+}) => React.ReactNode;
+
+/** @internal */
+type DefineRegistryActionFn = (
+  params: Record<string, unknown> | undefined,
+  setData: SetData,
+  data: DataModel,
+) => Promise<void>;
+
 // ============================================================================
 // NEW API
 // ============================================================================
@@ -289,7 +441,7 @@ export function createRenderer<
   TDef extends SchemaDefinition,
   TCatalog extends { components: Record<string, { props: unknown }> },
 >(
-  catalog: NewCatalog<TDef, TCatalog>,
+  catalog: Catalog<TDef, TCatalog>,
   components: ComponentMap<TCatalog["components"]>,
 ): ComponentType<CreateRendererProps> {
   // Convert component map to registry

+ 27 - 19
skills/json-render-react/SKILL.md

@@ -10,34 +10,27 @@ React renderer that converts JSON specs into React component trees.
 ## Quick Start
 
 ```typescript
-import { Renderer } from "@json-render/react";
+import { defineRegistry, Renderer } from "@json-render/react";
 import { catalog } from "./catalog";
 
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => <div>{props.title}{children}</div>,
+  },
+});
+
 function App({ spec }) {
-  return <Renderer spec={spec} catalog={catalog} />;
+  return <Renderer spec={spec} registry={registry} />;
 }
 ```
 
 ## Creating a Catalog
 
 ```typescript
-import { defineCatalog, defineComponents } from "@json-render/react";
-import { schema } from "@json-render/react"; // Uses element tree schema
+import { defineCatalog } from "@json-render/core";
+import { schema, defineRegistry } from "@json-render/react";
 import { z } from "zod";
 
-// Define component implementations
-const components = defineComponents(catalog, {
-  Button: ({ props }) => (
-    <button className={props.variant}>{props.label}</button>
-  ),
-  Card: ({ props, children }) => (
-    <div className="card">
-      <h2>{props.title}</h2>
-      {children}
-    </div>
-  ),
-});
-
 // Create catalog with props schemas
 export const catalog = defineCatalog(schema, {
   components: {
@@ -54,6 +47,21 @@ export const catalog = defineCatalog(schema, {
     },
   },
 });
+
+// Define component implementations with type-safe props
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Button: ({ props }) => (
+      <button className={props.variant}>{props.label}</button>
+    ),
+    Card: ({ props, children }) => (
+      <div className="card">
+        <h2>{props.title}</h2>
+        {children}
+      </div>
+    ),
+  },
+});
 ```
 
 ## Spec Structure (Element Tree)
@@ -85,8 +93,8 @@ The React schema uses an element tree format:
 
 | Export | Purpose |
 |--------|---------|
-| `Renderer` | Render spec to React components |
+| `defineRegistry` | Create a type-safe component registry from a catalog |
+| `Renderer` | Render a spec using a registry |
 | `schema` | Element tree schema |
-| `defineComponents` | Type-safe component registry |
 | `useData` | Access data context |
 | `useActions` | Access actions context |

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels