registry.tsx 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273
  1. "use client";
  2. import { useState } from "react";
  3. import {
  4. defineRegistry,
  5. useBoundProp,
  6. useStateBinding,
  7. useFieldValidation,
  8. } from "@json-render/react";
  9. import { toast } from "sonner";
  10. import { playgroundCatalog } from "./catalog";
  11. // shadcn components
  12. import { Button } from "@/components/ui/button";
  13. import { Input } from "@/components/ui/input";
  14. import { Label } from "@/components/ui/label";
  15. import { Textarea } from "@/components/ui/textarea";
  16. import { Checkbox } from "@/components/ui/checkbox";
  17. import { Switch } from "@/components/ui/switch";
  18. import { Progress } from "@/components/ui/progress";
  19. import { Separator } from "@/components/ui/separator";
  20. import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
  21. import {
  22. Dialog as DialogPrimitive,
  23. DialogContent,
  24. DialogDescription,
  25. DialogHeader,
  26. DialogTitle,
  27. } from "@/components/ui/dialog";
  28. import {
  29. Accordion as AccordionPrimitive,
  30. AccordionContent,
  31. AccordionItem,
  32. AccordionTrigger,
  33. } from "@/components/ui/accordion";
  34. import { Badge } from "@/components/ui/badge";
  35. import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
  36. import {
  37. Select,
  38. SelectContent,
  39. SelectItem,
  40. SelectTrigger,
  41. SelectValue,
  42. } from "@/components/ui/select";
  43. import {
  44. Carousel as CarouselPrimitive,
  45. CarouselContent,
  46. CarouselItem,
  47. CarouselNext,
  48. CarouselPrevious,
  49. } from "@/components/ui/carousel";
  50. import {
  51. Collapsible,
  52. CollapsibleContent,
  53. CollapsibleTrigger,
  54. } from "@/components/ui/collapsible";
  55. import {
  56. Table as TablePrimitive,
  57. TableBody,
  58. TableCaption,
  59. TableCell,
  60. TableHead,
  61. TableHeader,
  62. TableRow,
  63. } from "@/components/ui/table";
  64. import {
  65. Drawer as DrawerPrimitive,
  66. DrawerContent,
  67. DrawerDescription,
  68. DrawerHeader,
  69. DrawerTitle,
  70. } from "@/components/ui/drawer";
  71. import {
  72. DropdownMenu as DropdownMenuPrimitive,
  73. DropdownMenuContent,
  74. DropdownMenuItem,
  75. DropdownMenuTrigger,
  76. } from "@/components/ui/dropdown-menu";
  77. import {
  78. Pagination as PaginationPrimitive,
  79. PaginationContent,
  80. PaginationItem,
  81. PaginationLink,
  82. PaginationNext,
  83. PaginationPrevious,
  84. } from "@/components/ui/pagination";
  85. import {
  86. Popover as PopoverPrimitive,
  87. PopoverContent,
  88. PopoverTrigger,
  89. } from "@/components/ui/popover";
  90. import { Skeleton } from "@/components/ui/skeleton";
  91. import { Slider } from "@/components/ui/slider";
  92. import {
  93. Tabs as TabsPrimitive,
  94. TabsList,
  95. TabsTrigger,
  96. } from "@/components/ui/tabs";
  97. import { Toggle } from "@/components/ui/toggle";
  98. import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
  99. import {
  100. Tooltip as TooltipPrimitive,
  101. TooltipContent,
  102. TooltipProvider,
  103. TooltipTrigger,
  104. } from "@/components/ui/tooltip";
  105. // =============================================================================
  106. // Registry — components + actions, types inferred from catalog
  107. // =============================================================================
  108. export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
  109. components: {
  110. // ── Layout ────────────────────────────────────────────────────────
  111. Card: ({ props, children }) => {
  112. const maxWidthClass =
  113. props.maxWidth === "sm"
  114. ? "max-w-xs sm:min-w-[280px]"
  115. : props.maxWidth === "md"
  116. ? "max-w-sm sm:min-w-[320px]"
  117. : props.maxWidth === "lg"
  118. ? "max-w-md sm:min-w-[360px]"
  119. : "w-full";
  120. const centeredClass = props.centered ? "mx-auto" : "";
  121. return (
  122. <div
  123. className={`border border-border rounded-lg p-4 bg-card text-card-foreground overflow-hidden ${maxWidthClass} ${centeredClass}`}
  124. >
  125. {(props.title || props.description) && (
  126. <div className="mb-4">
  127. {props.title && (
  128. <h3 className="font-semibold text-lg text-left">
  129. {props.title}
  130. </h3>
  131. )}
  132. {props.description && (
  133. <p className="text-sm text-muted-foreground mt-1 text-left">
  134. {props.description}
  135. </p>
  136. )}
  137. </div>
  138. )}
  139. <div className="space-y-3">{children}</div>
  140. </div>
  141. );
  142. },
  143. Stack: ({ props, children }) => {
  144. const isHorizontal = props.direction === "horizontal";
  145. const gapClass =
  146. props.gap === "lg"
  147. ? "gap-4"
  148. : props.gap === "md"
  149. ? "gap-3"
  150. : props.gap === "sm"
  151. ? "gap-2"
  152. : props.gap === "none"
  153. ? "gap-0"
  154. : "gap-3";
  155. const alignClass =
  156. props.align === "center"
  157. ? "items-center"
  158. : props.align === "end"
  159. ? "items-end"
  160. : props.align === "stretch"
  161. ? "items-stretch"
  162. : "items-start";
  163. const justifyClass =
  164. props.justify === "center"
  165. ? "justify-center"
  166. : props.justify === "end"
  167. ? "justify-end"
  168. : props.justify === "between"
  169. ? "justify-between"
  170. : props.justify === "around"
  171. ? "justify-around"
  172. : "";
  173. return (
  174. <div
  175. className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
  176. >
  177. {children}
  178. </div>
  179. );
  180. },
  181. Grid: ({ props, children }) => {
  182. const n = props.columns ?? 1;
  183. const cols =
  184. n >= 6
  185. ? "grid-cols-6"
  186. : n >= 5
  187. ? "grid-cols-5"
  188. : n >= 4
  189. ? "grid-cols-4"
  190. : n >= 3
  191. ? "grid-cols-3"
  192. : n >= 2
  193. ? "grid-cols-2"
  194. : "grid-cols-1";
  195. const gridGap =
  196. props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
  197. return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
  198. },
  199. Separator: ({ props }) => (
  200. <Separator
  201. orientation={props.orientation ?? "horizontal"}
  202. className={props.orientation === "vertical" ? "h-full mx-2" : "my-3"}
  203. />
  204. ),
  205. Tabs: ({ props, bindings, emit }) => {
  206. const tabs = props.tabs ?? [];
  207. const [boundValue, setBoundValue] = useBoundProp<string>(
  208. props.value as string | undefined,
  209. bindings?.value,
  210. );
  211. const [localValue, setLocalValue] = useState(
  212. props.defaultValue ?? tabs[0]?.value ?? "",
  213. );
  214. const isBound = !!bindings?.value;
  215. const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
  216. const setValue = isBound ? setBoundValue : setLocalValue;
  217. return (
  218. <TabsPrimitive
  219. value={value}
  220. onValueChange={(v) => {
  221. setValue(v);
  222. emit("change");
  223. }}
  224. >
  225. <TabsList>
  226. {tabs.map((tab) => (
  227. <TabsTrigger key={tab.value} value={tab.value}>
  228. {tab.label}
  229. </TabsTrigger>
  230. ))}
  231. </TabsList>
  232. </TabsPrimitive>
  233. );
  234. },
  235. Accordion: ({ props }) => {
  236. const items = props.items ?? [];
  237. const accordionType = props.type ?? "single";
  238. if (accordionType === "multiple") {
  239. return (
  240. <AccordionPrimitive type="multiple" className="w-full">
  241. {items.map((item, i) => (
  242. <AccordionItem key={i} value={`item-${i}`}>
  243. <AccordionTrigger>{item.title}</AccordionTrigger>
  244. <AccordionContent>{item.content}</AccordionContent>
  245. </AccordionItem>
  246. ))}
  247. </AccordionPrimitive>
  248. );
  249. }
  250. return (
  251. <AccordionPrimitive type="single" collapsible className="w-full">
  252. {items.map((item, i) => (
  253. <AccordionItem key={i} value={`item-${i}`}>
  254. <AccordionTrigger>{item.title}</AccordionTrigger>
  255. <AccordionContent>{item.content}</AccordionContent>
  256. </AccordionItem>
  257. ))}
  258. </AccordionPrimitive>
  259. );
  260. },
  261. Collapsible: ({ props, children }) => {
  262. const [open, setOpen] = useState(props.defaultOpen ?? false);
  263. return (
  264. <Collapsible open={open} onOpenChange={setOpen} className="w-full">
  265. <CollapsibleTrigger asChild>
  266. <button className="flex w-full items-center justify-between rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-muted transition-colors">
  267. {props.title}
  268. <svg
  269. className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
  270. fill="none"
  271. viewBox="0 0 24 24"
  272. stroke="currentColor"
  273. strokeWidth={2}
  274. >
  275. <path
  276. strokeLinecap="round"
  277. strokeLinejoin="round"
  278. d="M19 9l-7 7-7-7"
  279. />
  280. </svg>
  281. </button>
  282. </CollapsibleTrigger>
  283. <CollapsibleContent className="pt-2">{children}</CollapsibleContent>
  284. </Collapsible>
  285. );
  286. },
  287. Dialog: ({ props, children }) => {
  288. const [open, setOpen] = useStateBinding<boolean>(props.openPath);
  289. return (
  290. <DialogPrimitive open={open ?? false} onOpenChange={(v) => setOpen(v)}>
  291. <DialogContent>
  292. <DialogHeader>
  293. <DialogTitle>{props.title}</DialogTitle>
  294. {props.description && (
  295. <DialogDescription>{props.description}</DialogDescription>
  296. )}
  297. </DialogHeader>
  298. {children}
  299. </DialogContent>
  300. </DialogPrimitive>
  301. );
  302. },
  303. Drawer: ({ props, children }) => {
  304. const [open, setOpen] = useStateBinding<boolean>(props.openPath);
  305. return (
  306. <DrawerPrimitive open={open ?? false} onOpenChange={(v) => setOpen(v)}>
  307. <DrawerContent>
  308. <DrawerHeader>
  309. <DrawerTitle>{props.title}</DrawerTitle>
  310. {props.description && (
  311. <DrawerDescription>{props.description}</DrawerDescription>
  312. )}
  313. </DrawerHeader>
  314. <div className="p-4">{children}</div>
  315. </DrawerContent>
  316. </DrawerPrimitive>
  317. );
  318. },
  319. Carousel: ({ props }) => {
  320. const items = props.items ?? [];
  321. return (
  322. <CarouselPrimitive className="w-full">
  323. <CarouselContent>
  324. {items.map((item, i) => (
  325. <CarouselItem
  326. key={i}
  327. className="basis-3/4 md:basis-1/2 lg:basis-1/3"
  328. >
  329. <div className="border border-border rounded-lg p-4 bg-card h-full">
  330. {item.title && (
  331. <h4 className="font-semibold text-sm mb-1">{item.title}</h4>
  332. )}
  333. {item.description && (
  334. <p className="text-sm text-muted-foreground">
  335. {item.description}
  336. </p>
  337. )}
  338. </div>
  339. </CarouselItem>
  340. ))}
  341. </CarouselContent>
  342. <CarouselPrevious />
  343. <CarouselNext />
  344. </CarouselPrimitive>
  345. );
  346. },
  347. // ── Data Display ──────────────────────────────────────────────────
  348. Table: ({ props }) => {
  349. const columns = props.columns ?? [];
  350. const rawRows: unknown[] = Array.isArray(props.rows) ? props.rows : [];
  351. const rows = rawRows.map((row) => {
  352. if (Array.isArray(row)) return row.map(String);
  353. if (row && typeof row === "object") {
  354. const obj = row as Record<string, unknown>;
  355. return columns.map((col) =>
  356. String(obj[col] ?? obj[col.toLowerCase()] ?? ""),
  357. );
  358. }
  359. return columns.map(() => "");
  360. });
  361. return (
  362. <div className="rounded-md border border-border overflow-hidden">
  363. <TablePrimitive>
  364. {props.caption && <TableCaption>{props.caption}</TableCaption>}
  365. <TableHeader>
  366. <TableRow>
  367. {columns.map((col) => (
  368. <TableHead key={col}>{col}</TableHead>
  369. ))}
  370. </TableRow>
  371. </TableHeader>
  372. <TableBody>
  373. {rows.map((row, i) => (
  374. <TableRow key={i}>
  375. {row.map((cell, j) => (
  376. <TableCell key={j}>{cell}</TableCell>
  377. ))}
  378. </TableRow>
  379. ))}
  380. </TableBody>
  381. </TablePrimitive>
  382. </div>
  383. );
  384. },
  385. Heading: ({ props }) => {
  386. const level = props.level ?? "h2";
  387. const headingClass =
  388. level === "h1"
  389. ? "text-2xl font-bold"
  390. : level === "h3"
  391. ? "text-base font-semibold"
  392. : level === "h4"
  393. ? "text-sm font-semibold"
  394. : "text-lg font-semibold";
  395. if (level === "h1")
  396. return <h1 className={`${headingClass} text-left`}>{props.text}</h1>;
  397. if (level === "h3")
  398. return <h3 className={`${headingClass} text-left`}>{props.text}</h3>;
  399. if (level === "h4")
  400. return <h4 className={`${headingClass} text-left`}>{props.text}</h4>;
  401. return <h2 className={`${headingClass} text-left`}>{props.text}</h2>;
  402. },
  403. Text: ({ props }) => {
  404. const textClass =
  405. props.variant === "caption"
  406. ? "text-xs"
  407. : props.variant === "muted"
  408. ? "text-sm text-muted-foreground"
  409. : props.variant === "lead"
  410. ? "text-xl text-muted-foreground"
  411. : props.variant === "code"
  412. ? "font-mono text-sm bg-muted px-1.5 py-0.5 rounded"
  413. : "text-sm";
  414. if (props.variant === "code") {
  415. return <code className={`${textClass} text-left`}>{props.text}</code>;
  416. }
  417. return <p className={`${textClass} text-left`}>{props.text}</p>;
  418. },
  419. Image: ({ props }) => (
  420. <div
  421. className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground aspect-video"
  422. style={{ width: props.width ?? 80, height: props.height ?? 60 }}
  423. >
  424. {props.alt || "img"}
  425. </div>
  426. ),
  427. Avatar: ({ props }) => {
  428. const name = props.name || "?";
  429. const initials = name
  430. .split(" ")
  431. .map((n) => n[0])
  432. .join("")
  433. .slice(0, 2)
  434. .toUpperCase();
  435. const avatarSize =
  436. props.size === "lg"
  437. ? "w-12 h-12 text-base"
  438. : props.size === "sm"
  439. ? "w-8 h-8 text-xs"
  440. : "w-10 h-10 text-sm";
  441. return (
  442. <div
  443. className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium`}
  444. >
  445. {initials}
  446. </div>
  447. );
  448. },
  449. Badge: ({ props }) => {
  450. const variant =
  451. props.variant === "success" || props.variant === "warning"
  452. ? "secondary"
  453. : props.variant === "danger"
  454. ? "destructive"
  455. : "default";
  456. const customClass =
  457. props.variant === "success"
  458. ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"
  459. : props.variant === "warning"
  460. ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"
  461. : "";
  462. return (
  463. <Badge variant={variant} className={customClass}>
  464. {props.text}
  465. </Badge>
  466. );
  467. },
  468. Alert: ({ props }) => {
  469. const variant = props.type === "error" ? "destructive" : "default";
  470. const customClass =
  471. props.type === "success"
  472. ? "border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100"
  473. : props.type === "warning"
  474. ? "border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-100"
  475. : props.type === "info"
  476. ? "border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100"
  477. : "";
  478. return (
  479. <Alert variant={variant} className={customClass}>
  480. <AlertTitle>{props.title}</AlertTitle>
  481. {props.message && (
  482. <AlertDescription>{props.message}</AlertDescription>
  483. )}
  484. </Alert>
  485. );
  486. },
  487. Progress: ({ props }) => {
  488. const value = Math.min(100, Math.max(0, props.value || 0));
  489. return (
  490. <div className="space-y-2">
  491. {props.label && (
  492. <Label className="text-sm text-muted-foreground">
  493. {props.label}
  494. </Label>
  495. )}
  496. <Progress value={value} />
  497. </div>
  498. );
  499. },
  500. Skeleton: ({ props }) => (
  501. <Skeleton
  502. className={props.rounded ? "rounded-full" : "rounded-md"}
  503. style={{
  504. width: props.width ?? "100%",
  505. height: props.height ?? "1.25rem",
  506. }}
  507. />
  508. ),
  509. Spinner: ({ props }) => {
  510. const sizeClass =
  511. props.size === "lg"
  512. ? "h-8 w-8"
  513. : props.size === "sm"
  514. ? "h-4 w-4"
  515. : "h-6 w-6";
  516. return (
  517. <div className="flex items-center gap-2">
  518. <svg
  519. className={`${sizeClass} animate-spin text-muted-foreground`}
  520. viewBox="0 0 24 24"
  521. fill="none"
  522. >
  523. <circle
  524. className="opacity-25"
  525. cx="12"
  526. cy="12"
  527. r="10"
  528. stroke="currentColor"
  529. strokeWidth="4"
  530. />
  531. <path
  532. className="opacity-75"
  533. fill="currentColor"
  534. d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
  535. />
  536. </svg>
  537. {props.label && (
  538. <span className="text-sm text-muted-foreground">{props.label}</span>
  539. )}
  540. </div>
  541. );
  542. },
  543. Tooltip: ({ props }) => (
  544. <TooltipProvider>
  545. <TooltipPrimitive>
  546. <TooltipTrigger asChild>
  547. <span className="text-sm underline decoration-dotted cursor-help">
  548. {props.text}
  549. </span>
  550. </TooltipTrigger>
  551. <TooltipContent>
  552. <p>{props.content}</p>
  553. </TooltipContent>
  554. </TooltipPrimitive>
  555. </TooltipProvider>
  556. ),
  557. Popover: ({ props }) => (
  558. <PopoverPrimitive>
  559. <PopoverTrigger asChild>
  560. <Button variant="outline" className="text-sm">
  561. {props.trigger}
  562. </Button>
  563. </PopoverTrigger>
  564. <PopoverContent className="w-64">
  565. <p className="text-sm">{props.content}</p>
  566. </PopoverContent>
  567. </PopoverPrimitive>
  568. ),
  569. Rating: ({ props }) => {
  570. const ratingValue = props.value || 0;
  571. const maxRating = props.max ?? 5;
  572. return (
  573. <div className="space-y-2">
  574. {props.label && (
  575. <Label className="text-sm text-muted-foreground">
  576. {props.label}
  577. </Label>
  578. )}
  579. <div className="flex gap-1">
  580. {Array.from({ length: maxRating }).map((_, i) => (
  581. <span
  582. key={i}
  583. className={`text-lg ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
  584. >
  585. *
  586. </span>
  587. ))}
  588. </div>
  589. </div>
  590. );
  591. },
  592. // ── Charts ────────────────────────────────────────────────────────
  593. BarGraph: ({ props }) => {
  594. const data = props.data || [];
  595. const maxValue = Math.max(...data.map((d) => d.value), 1);
  596. return (
  597. <div className="space-y-2">
  598. {props.title && (
  599. <div className="text-sm font-medium text-left">{props.title}</div>
  600. )}
  601. <div className="flex gap-2">
  602. {data.map((d, i) => (
  603. <div key={i} className="flex-1 flex flex-col items-center gap-1">
  604. <div className="text-xs text-muted-foreground">{d.value}</div>
  605. <div className="w-full h-24 flex items-end">
  606. <div
  607. className="w-full bg-primary rounded-t transition-all"
  608. style={{
  609. height: `${(d.value / maxValue) * 100}%`,
  610. minHeight: 2,
  611. }}
  612. />
  613. </div>
  614. <div className="text-xs text-muted-foreground truncate w-full text-center">
  615. {d.label}
  616. </div>
  617. </div>
  618. ))}
  619. </div>
  620. </div>
  621. );
  622. },
  623. LineGraph: ({ props }) => {
  624. const data = props.data || [];
  625. const maxValue = Math.max(...data.map((d) => d.value));
  626. const minValue = Math.min(...data.map((d) => d.value));
  627. const range = maxValue - minValue || 1;
  628. const width = 300;
  629. const height = 100;
  630. const padding = { top: 10, right: 10, bottom: 10, left: 10 };
  631. const chartWidth = width - padding.left - padding.right;
  632. const chartHeight = height - padding.top - padding.bottom;
  633. const points = data.map((d, i) => {
  634. const x =
  635. padding.left +
  636. (data.length > 1
  637. ? (i / (data.length - 1)) * chartWidth
  638. : chartWidth / 2);
  639. const y =
  640. padding.top +
  641. chartHeight -
  642. ((d.value - minValue) / range) * chartHeight;
  643. return { x, y, ...d };
  644. });
  645. const pathD =
  646. points.length > 0
  647. ? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
  648. : "";
  649. return (
  650. <div className="space-y-2">
  651. {props.title && (
  652. <div className="text-sm font-medium text-left">{props.title}</div>
  653. )}
  654. <div className="relative h-28">
  655. <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
  656. <line
  657. x1={padding.left}
  658. y1={padding.top + chartHeight / 2}
  659. x2={width - padding.right}
  660. y2={padding.top + chartHeight / 2}
  661. stroke="currentColor"
  662. strokeOpacity="0.1"
  663. strokeWidth="1"
  664. />
  665. <line
  666. x1={padding.left}
  667. y1={padding.top}
  668. x2={width - padding.right}
  669. y2={padding.top}
  670. stroke="currentColor"
  671. strokeOpacity="0.1"
  672. strokeWidth="1"
  673. />
  674. <line
  675. x1={padding.left}
  676. y1={height - padding.bottom}
  677. x2={width - padding.right}
  678. y2={height - padding.bottom}
  679. stroke="currentColor"
  680. strokeOpacity="0.1"
  681. strokeWidth="1"
  682. />
  683. {pathD && (
  684. <path
  685. d={pathD}
  686. fill="none"
  687. stroke="currentColor"
  688. strokeWidth="2"
  689. strokeLinecap="round"
  690. strokeLinejoin="round"
  691. className="text-primary"
  692. />
  693. )}
  694. {points.map((p, i) => (
  695. <circle
  696. key={i}
  697. cx={p.x}
  698. cy={p.y}
  699. r="4"
  700. className="fill-primary"
  701. />
  702. ))}
  703. </svg>
  704. </div>
  705. {data.length > 0 && (
  706. <div className="flex justify-between">
  707. {data.map((d, i) => (
  708. <div
  709. key={i}
  710. className="text-xs text-muted-foreground text-center"
  711. style={{ width: `${100 / data.length}%` }}
  712. >
  713. {d.label}
  714. </div>
  715. ))}
  716. </div>
  717. )}
  718. </div>
  719. );
  720. },
  721. // ── Form Inputs ───────────────────────────────────────────────────
  722. Input: ({ props, bindings, emit }) => {
  723. const [boundValue, setBoundValue] = useBoundProp<string>(
  724. props.value as string | undefined,
  725. bindings?.value,
  726. );
  727. const [localValue, setLocalValue] = useState("");
  728. const isBound = !!bindings?.value;
  729. const value = isBound ? (boundValue ?? "") : localValue;
  730. const setValue = isBound ? setBoundValue : setLocalValue;
  731. const hasValidation = !!(bindings?.value && props.checks?.length);
  732. const { errors, validate } = useFieldValidation(
  733. bindings?.value ?? "",
  734. hasValidation ? { checks: props.checks ?? [] } : undefined,
  735. );
  736. return (
  737. <div className="space-y-2">
  738. {props.label && <Label htmlFor={props.name}>{props.label}</Label>}
  739. <Input
  740. id={props.name}
  741. name={props.name}
  742. type={props.type ?? "text"}
  743. placeholder={props.placeholder ?? ""}
  744. value={value}
  745. onChange={(e) => setValue(e.target.value)}
  746. onKeyDown={(e) => {
  747. if (e.key === "Enter") emit("submit");
  748. }}
  749. onFocus={() => emit("focus")}
  750. onBlur={() => {
  751. if (hasValidation) validate();
  752. emit("blur");
  753. }}
  754. />
  755. {errors.length > 0 && (
  756. <p className="text-sm text-destructive">{errors[0]}</p>
  757. )}
  758. </div>
  759. );
  760. },
  761. Textarea: ({ props, bindings }) => {
  762. const [boundValue, setBoundValue] = useBoundProp<string>(
  763. props.value as string | undefined,
  764. bindings?.value,
  765. );
  766. const [localValue, setLocalValue] = useState("");
  767. const isBound = !!bindings?.value;
  768. const value = isBound ? (boundValue ?? "") : localValue;
  769. const setValue = isBound ? setBoundValue : setLocalValue;
  770. const hasValidation = !!(bindings?.value && props.checks?.length);
  771. const { errors, validate } = useFieldValidation(
  772. bindings?.value ?? "",
  773. hasValidation ? { checks: props.checks ?? [] } : undefined,
  774. );
  775. return (
  776. <div className="space-y-2">
  777. {props.label && <Label htmlFor={props.name}>{props.label}</Label>}
  778. <Textarea
  779. id={props.name}
  780. name={props.name}
  781. placeholder={props.placeholder ?? ""}
  782. rows={props.rows ?? 3}
  783. value={value}
  784. onChange={(e) => setValue(e.target.value)}
  785. onBlur={() => {
  786. if (hasValidation) validate();
  787. }}
  788. />
  789. {errors.length > 0 && (
  790. <p className="text-sm text-destructive">{errors[0]}</p>
  791. )}
  792. </div>
  793. );
  794. },
  795. Select: ({ props, bindings, emit }) => {
  796. const [boundValue, setBoundValue] = useBoundProp<string>(
  797. props.value as string | undefined,
  798. bindings?.value,
  799. );
  800. const [localValue, setLocalValue] = useState<string>("");
  801. const isBound = !!bindings?.value;
  802. const value = isBound ? (boundValue ?? "") : localValue;
  803. const setValue = isBound ? setBoundValue : setLocalValue;
  804. const rawOptions = props.options ?? [];
  805. // Coerce options to strings – AI may produce objects/numbers instead of
  806. // plain strings which would cause duplicate `[object Object]` keys.
  807. const options = rawOptions.map((opt) =>
  808. typeof opt === "string" ? opt : String(opt ?? ""),
  809. );
  810. const hasValidation = !!(bindings?.value && props.checks?.length);
  811. const { errors, validate } = useFieldValidation(
  812. bindings?.value ?? "",
  813. hasValidation ? { checks: props.checks ?? [] } : undefined,
  814. );
  815. return (
  816. <div className="space-y-2">
  817. <Label>{props.label}</Label>
  818. <Select
  819. value={value}
  820. onValueChange={(v) => {
  821. setValue(v);
  822. if (hasValidation) validate();
  823. emit("change");
  824. }}
  825. >
  826. <SelectTrigger className="w-full">
  827. <SelectValue placeholder={props.placeholder ?? "Select..."} />
  828. </SelectTrigger>
  829. <SelectContent>
  830. {options.map((opt, idx) => (
  831. <SelectItem
  832. key={`${idx}-${opt}`}
  833. value={opt || `option-${idx}`}
  834. >
  835. {opt}
  836. </SelectItem>
  837. ))}
  838. </SelectContent>
  839. </Select>
  840. {errors.length > 0 && (
  841. <p className="text-sm text-destructive">{errors[0]}</p>
  842. )}
  843. </div>
  844. );
  845. },
  846. Checkbox: ({ props, bindings, emit }) => {
  847. const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
  848. props.checked as boolean | undefined,
  849. bindings?.checked,
  850. );
  851. const [localChecked, setLocalChecked] = useState(!!props.checked);
  852. const isBound = !!bindings?.checked;
  853. const checked = isBound ? (boundChecked ?? false) : localChecked;
  854. const setChecked = isBound ? setBoundChecked : setLocalChecked;
  855. return (
  856. <div className="flex items-center space-x-2">
  857. <Checkbox
  858. id={props.name}
  859. checked={checked}
  860. onCheckedChange={(c) => {
  861. setChecked(c === true);
  862. emit("change");
  863. }}
  864. />
  865. <Label htmlFor={props.name} className="cursor-pointer">
  866. {props.label}
  867. </Label>
  868. </div>
  869. );
  870. },
  871. Radio: ({ props, bindings, emit }) => {
  872. const rawOptions = props.options ?? [];
  873. const options = rawOptions.map((opt) =>
  874. typeof opt === "string" ? opt : String(opt ?? ""),
  875. );
  876. const [boundValue, setBoundValue] = useBoundProp<string>(
  877. props.value as string | undefined,
  878. bindings?.value,
  879. );
  880. const [localValue, setLocalValue] = useState(options[0] ?? "");
  881. const isBound = !!bindings?.value;
  882. const value = isBound ? (boundValue ?? "") : localValue;
  883. const setValue = isBound ? setBoundValue : setLocalValue;
  884. return (
  885. <div className="space-y-2">
  886. {props.label && <Label>{props.label}</Label>}
  887. <RadioGroup
  888. value={value}
  889. onValueChange={(v) => {
  890. setValue(v);
  891. emit("change");
  892. }}
  893. >
  894. {options.map((opt, idx) => (
  895. <div
  896. key={`${idx}-${opt}`}
  897. className="flex items-center space-x-2"
  898. >
  899. <RadioGroupItem
  900. value={opt || `option-${idx}`}
  901. id={`${props.name}-${idx}-${opt}`}
  902. />
  903. <Label
  904. htmlFor={`${props.name}-${idx}-${opt}`}
  905. className="cursor-pointer"
  906. >
  907. {opt}
  908. </Label>
  909. </div>
  910. ))}
  911. </RadioGroup>
  912. </div>
  913. );
  914. },
  915. Switch: ({ props, bindings, emit }) => {
  916. const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
  917. props.checked as boolean | undefined,
  918. bindings?.checked,
  919. );
  920. const [localChecked, setLocalChecked] = useState(!!props.checked);
  921. const isBound = !!bindings?.checked;
  922. const checked = isBound ? (boundChecked ?? false) : localChecked;
  923. const setChecked = isBound ? setBoundChecked : setLocalChecked;
  924. return (
  925. <div className="flex items-center justify-between space-x-2">
  926. <Label htmlFor={props.name} className="cursor-pointer">
  927. {props.label}
  928. </Label>
  929. <Switch
  930. id={props.name}
  931. checked={checked}
  932. onCheckedChange={(c) => {
  933. setChecked(c);
  934. emit("change");
  935. }}
  936. />
  937. </div>
  938. );
  939. },
  940. Slider: ({ props, bindings, emit }) => {
  941. const [boundValue, setBoundValue] = useBoundProp<number>(
  942. props.value as number | undefined,
  943. bindings?.value,
  944. );
  945. const [localValue, setLocalValue] = useState(props.min ?? 0);
  946. const isBound = !!bindings?.value;
  947. const value = isBound ? (boundValue ?? props.min ?? 0) : localValue;
  948. const setValue = isBound ? setBoundValue : setLocalValue;
  949. return (
  950. <div className="space-y-2">
  951. {props.label && (
  952. <div className="flex justify-between">
  953. <Label className="text-sm">{props.label}</Label>
  954. <span className="text-sm text-muted-foreground">{value}</span>
  955. </div>
  956. )}
  957. <Slider
  958. value={[value]}
  959. min={props.min ?? 0}
  960. max={props.max ?? 100}
  961. step={props.step ?? 1}
  962. onValueChange={(v) => {
  963. setValue(v[0] ?? 0);
  964. emit("change");
  965. }}
  966. />
  967. </div>
  968. );
  969. },
  970. // ── Actions ───────────────────────────────────────────────────────
  971. Button: ({ props, emit }) => {
  972. const variant =
  973. props.variant === "danger"
  974. ? "destructive"
  975. : props.variant === "secondary"
  976. ? "secondary"
  977. : "default";
  978. return (
  979. <Button
  980. variant={variant}
  981. disabled={props.disabled ?? false}
  982. onClick={() => emit("press")}
  983. >
  984. {props.label}
  985. </Button>
  986. );
  987. },
  988. Link: ({ props, emit }) => (
  989. <Button
  990. variant="link"
  991. className="h-auto p-0"
  992. onClick={() => emit("press")}
  993. >
  994. {props.label}
  995. </Button>
  996. ),
  997. DropdownMenu: ({ props, emit }) => {
  998. const items = props.items ?? [];
  999. return (
  1000. <DropdownMenuPrimitive>
  1001. <DropdownMenuTrigger asChild>
  1002. <Button variant="outline">{props.label}</Button>
  1003. </DropdownMenuTrigger>
  1004. <DropdownMenuContent>
  1005. {items.map((item) => (
  1006. <DropdownMenuItem key={item.value} onClick={() => emit("select")}>
  1007. {item.label}
  1008. </DropdownMenuItem>
  1009. ))}
  1010. </DropdownMenuContent>
  1011. </DropdownMenuPrimitive>
  1012. );
  1013. },
  1014. Toggle: ({ props, bindings, emit }) => {
  1015. const [boundPressed, setBoundPressed] = useBoundProp<boolean>(
  1016. props.pressed as boolean | undefined,
  1017. bindings?.pressed,
  1018. );
  1019. const [localPressed, setLocalPressed] = useState(props.pressed ?? false);
  1020. const isBound = !!bindings?.pressed;
  1021. const pressed = isBound ? (boundPressed ?? false) : localPressed;
  1022. const setPressed = isBound ? setBoundPressed : setLocalPressed;
  1023. return (
  1024. <Toggle
  1025. variant={props.variant ?? "default"}
  1026. pressed={pressed}
  1027. onPressedChange={(v) => {
  1028. setPressed(v);
  1029. emit("change");
  1030. }}
  1031. >
  1032. {props.label}
  1033. </Toggle>
  1034. );
  1035. },
  1036. ToggleGroup: ({ props, bindings, emit }) => {
  1037. const type = props.type ?? "single";
  1038. const items = props.items ?? [];
  1039. const [boundValue, setBoundValue] = useBoundProp<string>(
  1040. props.value as string | undefined,
  1041. bindings?.value,
  1042. );
  1043. const [localValue, setLocalValue] = useState(items[0]?.value ?? "");
  1044. const isBound = !!bindings?.value;
  1045. const value = isBound ? (boundValue ?? "") : localValue;
  1046. const setValue = isBound ? setBoundValue : setLocalValue;
  1047. if (type === "multiple") {
  1048. return (
  1049. <ToggleGroup type="multiple">
  1050. {items.map((item) => (
  1051. <ToggleGroupItem key={item.value} value={item.value}>
  1052. {item.label}
  1053. </ToggleGroupItem>
  1054. ))}
  1055. </ToggleGroup>
  1056. );
  1057. }
  1058. return (
  1059. <ToggleGroup
  1060. type="single"
  1061. value={value}
  1062. onValueChange={(v) => {
  1063. if (v) {
  1064. setValue(v);
  1065. emit("change");
  1066. }
  1067. }}
  1068. >
  1069. {items.map((item) => (
  1070. <ToggleGroupItem key={item.value} value={item.value}>
  1071. {item.label}
  1072. </ToggleGroupItem>
  1073. ))}
  1074. </ToggleGroup>
  1075. );
  1076. },
  1077. ButtonGroup: ({ props, bindings, emit }) => {
  1078. const buttons = props.buttons ?? [];
  1079. const [boundSelected, setBoundSelected] = useBoundProp<string>(
  1080. props.selected as string | undefined,
  1081. bindings?.selected,
  1082. );
  1083. const [localValue, setLocalValue] = useState(buttons[0]?.value ?? "");
  1084. const isBound = !!bindings?.selected;
  1085. const value = isBound ? (boundSelected ?? "") : localValue;
  1086. const setValue = isBound ? setBoundSelected : setLocalValue;
  1087. return (
  1088. <div className="inline-flex rounded-md border border-border">
  1089. {buttons.map((btn, i) => (
  1090. <button
  1091. key={btn.value}
  1092. className={`px-3 py-1.5 text-sm transition-colors ${
  1093. value === btn.value
  1094. ? "bg-primary text-primary-foreground"
  1095. : "bg-background hover:bg-muted"
  1096. } ${i > 0 ? "border-l border-border" : ""} ${
  1097. i === 0 ? "rounded-l-md" : ""
  1098. } ${i === buttons.length - 1 ? "rounded-r-md" : ""}`}
  1099. onClick={() => {
  1100. setValue(btn.value);
  1101. emit("change");
  1102. }}
  1103. >
  1104. {btn.label}
  1105. </button>
  1106. ))}
  1107. </div>
  1108. );
  1109. },
  1110. Pagination: ({ props, bindings, emit }) => {
  1111. const [boundPage, setBoundPage] = useBoundProp<number>(
  1112. props.page as number | undefined,
  1113. bindings?.page,
  1114. );
  1115. const currentPage = boundPage ?? 1;
  1116. const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
  1117. return (
  1118. <PaginationPrimitive>
  1119. <PaginationContent>
  1120. <PaginationItem>
  1121. <PaginationPrevious
  1122. href="#"
  1123. onClick={(e) => {
  1124. e.preventDefault();
  1125. if (currentPage > 1) {
  1126. setBoundPage(currentPage - 1);
  1127. emit("change");
  1128. }
  1129. }}
  1130. />
  1131. </PaginationItem>
  1132. {pages.map((page) => (
  1133. <PaginationItem key={page}>
  1134. <PaginationLink
  1135. href="#"
  1136. isActive={page === currentPage}
  1137. onClick={(e) => {
  1138. e.preventDefault();
  1139. setBoundPage(page);
  1140. emit("change");
  1141. }}
  1142. >
  1143. {page}
  1144. </PaginationLink>
  1145. </PaginationItem>
  1146. ))}
  1147. <PaginationItem>
  1148. <PaginationNext
  1149. href="#"
  1150. onClick={(e) => {
  1151. e.preventDefault();
  1152. if (currentPage < props.totalPages) {
  1153. setBoundPage(currentPage + 1);
  1154. emit("change");
  1155. }
  1156. }}
  1157. />
  1158. </PaginationItem>
  1159. </PaginationContent>
  1160. </PaginationPrimitive>
  1161. );
  1162. },
  1163. },
  1164. actions: {
  1165. // Built-in state actions — handled by ActionProvider, stubs needed for types
  1166. setState: async () => {},
  1167. pushState: async () => {},
  1168. removeState: async () => {},
  1169. // Demo actions — show toasts
  1170. buttonClick: async (params) => {
  1171. const message = (params?.message as string) || "Button clicked!";
  1172. toast.success(message);
  1173. },
  1174. formSubmit: async (params) => {
  1175. const formName = (params?.formName as string) || "Form";
  1176. toast.success(`${formName} submitted successfully!`);
  1177. },
  1178. linkClick: async (params) => {
  1179. const href = (params?.href as string) || "#";
  1180. toast.info(`Navigating to: ${href}`);
  1181. },
  1182. },
  1183. });
  1184. // Fallback component for unknown types
  1185. export function Fallback({ type }: { type: string }) {
  1186. return <div className="text-xs text-muted-foreground">[{type}]</div>;
  1187. }