@multicomma/arkcn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +78 -0
  2. package/bin/arkcn.mjs +78 -0
  3. package/cli/commands.mjs +251 -0
  4. package/cli/project.mjs +168 -0
  5. package/cli/registry.mjs +50 -0
  6. package/cli/ui.mjs +46 -0
  7. package/mcp/server.mjs +149 -0
  8. package/package.json +201 -0
  9. package/registry/base.css +680 -0
  10. package/registry/index.json +1833 -0
  11. package/registry/items/accordion.json +18 -0
  12. package/registry/items/activity-feed.json +18 -0
  13. package/registry/items/alert-dialog.json +18 -0
  14. package/registry/items/alert.json +17 -0
  15. package/registry/items/angle-slider.json +17 -0
  16. package/registry/items/app-shell.json +28 -0
  17. package/registry/items/aspect-ratio.json +13 -0
  18. package/registry/items/attachment.json +19 -0
  19. package/registry/items/avatar.json +17 -0
  20. package/registry/items/badge.json +18 -0
  21. package/registry/items/breadcrumb.json +18 -0
  22. package/registry/items/bubble.json +18 -0
  23. package/registry/items/button-group.json +19 -0
  24. package/registry/items/button.json +18 -0
  25. package/registry/items/calendar.json +19 -0
  26. package/registry/items/canvas.json +24 -0
  27. package/registry/items/card.json +15 -0
  28. package/registry/items/carousel.json +19 -0
  29. package/registry/items/cascader.json +23 -0
  30. package/registry/items/chart.json +17 -0
  31. package/registry/items/chat.json +23 -0
  32. package/registry/items/checkbox.json +18 -0
  33. package/registry/items/clipboard.json +19 -0
  34. package/registry/items/collapsible.json +15 -0
  35. package/registry/items/color-picker.json +19 -0
  36. package/registry/items/combobox.json +20 -0
  37. package/registry/items/command.json +21 -0
  38. package/registry/items/comment-thread.json +25 -0
  39. package/registry/items/context-menu.json +18 -0
  40. package/registry/items/controllable.json +14 -0
  41. package/registry/items/data-grid.json +24 -0
  42. package/registry/items/data-table-adapter.json +14 -0
  43. package/registry/items/data-table.json +32 -0
  44. package/registry/items/date-input.json +17 -0
  45. package/registry/items/dialog.json +19 -0
  46. package/registry/items/direction.json +13 -0
  47. package/registry/items/drag-preview.json +16 -0
  48. package/registry/items/drawer.json +18 -0
  49. package/registry/items/dropdown-menu.json +18 -0
  50. package/registry/items/editable.json +17 -0
  51. package/registry/items/empty.json +17 -0
  52. package/registry/items/field.json +19 -0
  53. package/registry/items/file-upload.json +19 -0
  54. package/registry/items/floating-panel.json +19 -0
  55. package/registry/items/floating-toolbar.json +16 -0
  56. package/registry/items/form.json +14 -0
  57. package/registry/items/format.json +15 -0
  58. package/registry/items/gantt.json +22 -0
  59. package/registry/items/history.json +14 -0
  60. package/registry/items/hotkeys.json +18 -0
  61. package/registry/items/hover-card.json +17 -0
  62. package/registry/items/image-cropper.json +17 -0
  63. package/registry/items/input-group.json +20 -0
  64. package/registry/items/input-otp.json +18 -0
  65. package/registry/items/input.json +15 -0
  66. package/registry/items/item.json +19 -0
  67. package/registry/items/json-tree-view.json +17 -0
  68. package/registry/items/kanban.json +26 -0
  69. package/registry/items/kbd.json +15 -0
  70. package/registry/items/label.json +15 -0
  71. package/registry/items/live-region.json +16 -0
  72. package/registry/items/marker.json +18 -0
  73. package/registry/items/marquee.json +17 -0
  74. package/registry/items/menubar.json +19 -0
  75. package/registry/items/message.json +15 -0
  76. package/registry/items/navigation-menu.json +19 -0
  77. package/registry/items/node-graph.json +22 -0
  78. package/registry/items/number-input.json +18 -0
  79. package/registry/items/pagination.json +18 -0
  80. package/registry/items/password-input.json +18 -0
  81. package/registry/items/popconfirm.json +20 -0
  82. package/registry/items/popover.json +18 -0
  83. package/registry/items/progress.json +17 -0
  84. package/registry/items/qr-code.json +17 -0
  85. package/registry/items/query-builder.json +23 -0
  86. package/registry/items/radio-group.json +17 -0
  87. package/registry/items/rating-group.json +18 -0
  88. package/registry/items/resizable.json +17 -0
  89. package/registry/items/rich-text-editor.json +37 -0
  90. package/registry/items/row-layout.json +15 -0
  91. package/registry/items/scheduler.json +23 -0
  92. package/registry/items/scroll-area.json +17 -0
  93. package/registry/items/segment-group.json +17 -0
  94. package/registry/items/select.json +19 -0
  95. package/registry/items/separator.json +15 -0
  96. package/registry/items/sheet.json +19 -0
  97. package/registry/items/sidebar.json +27 -0
  98. package/registry/items/signature-pad.json +19 -0
  99. package/registry/items/skeleton.json +15 -0
  100. package/registry/items/slider.json +17 -0
  101. package/registry/items/sonner.json +17 -0
  102. package/registry/items/spinner.json +17 -0
  103. package/registry/items/steps.json +19 -0
  104. package/registry/items/swap.json +17 -0
  105. package/registry/items/switch.json +17 -0
  106. package/registry/items/table.json +16 -0
  107. package/registry/items/tabs.json +18 -0
  108. package/registry/items/tags-input.json +18 -0
  109. package/registry/items/textarea.json +15 -0
  110. package/registry/items/time.json +14 -0
  111. package/registry/items/timer.json +18 -0
  112. package/registry/items/toc.json +17 -0
  113. package/registry/items/toggle-group.json +19 -0
  114. package/registry/items/toggle.json +18 -0
  115. package/registry/items/tooltip.json +17 -0
  116. package/registry/items/tour.json +19 -0
  117. package/registry/items/transfer-list.json +24 -0
  118. package/registry/items/tree-select.json +24 -0
  119. package/registry/items/tree-view.json +18 -0
  120. package/registry/items/use-mobile.json +13 -0
  121. package/registry/items/utils.json +17 -0
  122. package/registry/items/virtual-list.json +16 -0
  123. package/src/components/ui/accordion.tsx +111 -0
  124. package/src/components/ui/activity-feed.tsx +228 -0
  125. package/src/components/ui/alert-dialog.tsx +204 -0
  126. package/src/components/ui/alert.tsx +55 -0
  127. package/src/components/ui/angle-slider.tsx +117 -0
  128. package/src/components/ui/app-shell.tsx +985 -0
  129. package/src/components/ui/aspect-ratio.tsx +35 -0
  130. package/src/components/ui/attachment.tsx +193 -0
  131. package/src/components/ui/avatar.tsx +94 -0
  132. package/src/components/ui/badge.tsx +45 -0
  133. package/src/components/ui/breadcrumb.tsx +93 -0
  134. package/src/components/ui/bubble.tsx +119 -0
  135. package/src/components/ui/button-group.tsx +79 -0
  136. package/src/components/ui/button.tsx +65 -0
  137. package/src/components/ui/calendar.tsx +172 -0
  138. package/src/components/ui/canvas.tsx +723 -0
  139. package/src/components/ui/card.tsx +69 -0
  140. package/src/components/ui/carousel.tsx +228 -0
  141. package/src/components/ui/cascader.tsx +754 -0
  142. package/src/components/ui/chart.tsx +303 -0
  143. package/src/components/ui/chat.tsx +544 -0
  144. package/src/components/ui/checkbox.tsx +94 -0
  145. package/src/components/ui/clipboard.tsx +94 -0
  146. package/src/components/ui/collapsible.tsx +32 -0
  147. package/src/components/ui/color-picker.tsx +441 -0
  148. package/src/components/ui/combobox.tsx +400 -0
  149. package/src/components/ui/command.tsx +292 -0
  150. package/src/components/ui/comment-thread.tsx +923 -0
  151. package/src/components/ui/context-menu.tsx +331 -0
  152. package/src/components/ui/data-grid.tsx +1227 -0
  153. package/src/components/ui/data-table.tsx +993 -0
  154. package/src/components/ui/date-input.tsx +103 -0
  155. package/src/components/ui/dialog.tsx +159 -0
  156. package/src/components/ui/direction.tsx +32 -0
  157. package/src/components/ui/drawer.tsx +200 -0
  158. package/src/components/ui/dropdown-menu.tsx +329 -0
  159. package/src/components/ui/editable.tsx +90 -0
  160. package/src/components/ui/empty.tsx +86 -0
  161. package/src/components/ui/field.tsx +218 -0
  162. package/src/components/ui/file-upload.tsx +204 -0
  163. package/src/components/ui/floating-panel.tsx +199 -0
  164. package/src/components/ui/floating-toolbar.tsx +79 -0
  165. package/src/components/ui/format.tsx +22 -0
  166. package/src/components/ui/gantt.tsx +950 -0
  167. package/src/components/ui/hotkeys.tsx +340 -0
  168. package/src/components/ui/hover-card.tsx +83 -0
  169. package/src/components/ui/image-cropper.tsx +100 -0
  170. package/src/components/ui/input-group.tsx +133 -0
  171. package/src/components/ui/input-otp.tsx +82 -0
  172. package/src/components/ui/input.tsx +18 -0
  173. package/src/components/ui/item.tsx +174 -0
  174. package/src/components/ui/json-tree-view.tsx +37 -0
  175. package/src/components/ui/kanban.tsx +884 -0
  176. package/src/components/ui/kbd.tsx +20 -0
  177. package/src/components/ui/label.tsx +30 -0
  178. package/src/components/ui/live-region.tsx +54 -0
  179. package/src/components/ui/marker.tsx +66 -0
  180. package/src/components/ui/marquee.tsx +61 -0
  181. package/src/components/ui/menubar.tsx +456 -0
  182. package/src/components/ui/message.tsx +74 -0
  183. package/src/components/ui/navigation-menu.tsx +202 -0
  184. package/src/components/ui/node-graph.tsx +1723 -0
  185. package/src/components/ui/number-input.tsx +148 -0
  186. package/src/components/ui/pagination.tsx +88 -0
  187. package/src/components/ui/password-input.tsx +104 -0
  188. package/src/components/ui/popconfirm.tsx +149 -0
  189. package/src/components/ui/popover.tsx +126 -0
  190. package/src/components/ui/progress.tsx +114 -0
  191. package/src/components/ui/qr-code.tsx +51 -0
  192. package/src/components/ui/query-builder.tsx +938 -0
  193. package/src/components/ui/radio-group.tsx +108 -0
  194. package/src/components/ui/rating-group.tsx +91 -0
  195. package/src/components/ui/resizable.tsx +41 -0
  196. package/src/components/ui/rich-text-editor.tsx +845 -0
  197. package/src/components/ui/scheduler.tsx +1164 -0
  198. package/src/components/ui/scroll-area.tsx +82 -0
  199. package/src/components/ui/segment-group.tsx +95 -0
  200. package/src/components/ui/select.tsx +354 -0
  201. package/src/components/ui/separator.tsx +32 -0
  202. package/src/components/ui/sheet.tsx +130 -0
  203. package/src/components/ui/sidebar.tsx +665 -0
  204. package/src/components/ui/signature-pad.tsx +112 -0
  205. package/src/components/ui/skeleton.tsx +7 -0
  206. package/src/components/ui/slider.tsx +183 -0
  207. package/src/components/ui/sonner.tsx +37 -0
  208. package/src/components/ui/spinner.tsx +16 -0
  209. package/src/components/ui/steps.tsx +152 -0
  210. package/src/components/ui/swap.tsx +33 -0
  211. package/src/components/ui/switch.tsx +83 -0
  212. package/src/components/ui/table.tsx +72 -0
  213. package/src/components/ui/tabs.tsx +100 -0
  214. package/src/components/ui/tags-input.tsx +153 -0
  215. package/src/components/ui/textarea.tsx +17 -0
  216. package/src/components/ui/timer.tsx +67 -0
  217. package/src/components/ui/toc.tsx +64 -0
  218. package/src/components/ui/toggle-group.tsx +89 -0
  219. package/src/components/ui/toggle.tsx +46 -0
  220. package/src/components/ui/tooltip.tsx +113 -0
  221. package/src/components/ui/tour.tsx +168 -0
  222. package/src/components/ui/transfer-list.tsx +700 -0
  223. package/src/components/ui/tree-select.tsx +638 -0
  224. package/src/components/ui/tree-view.tsx +217 -0
  225. package/src/components/ui/virtual-list.tsx +343 -0
  226. package/src/hooks/use-mobile.ts +19 -0
  227. package/src/lib/controllable.ts +31 -0
  228. package/src/lib/data-table-adapter.ts +67 -0
  229. package/src/lib/drag-preview.ts +39 -0
  230. package/src/lib/form.ts +39 -0
  231. package/src/lib/history.ts +55 -0
  232. package/src/lib/row-layout.ts +81 -0
  233. package/src/lib/table.ts +483 -0
  234. package/src/lib/time.ts +37 -0
  235. package/src/lib/utils.ts +6 -0
  236. package/src/styles/arkcn.css +764 -0
  237. package/src/styles/index.css +3 -0
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "input",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {},
6
+ "registryDependencies": [
7
+ "utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "components/ui/input.tsx",
12
+ "content": "import * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n return (\n <input\n type={type}\n data-slot=\"input\"\n className={cn(\n \"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n className\n )}\n {...props}\n />\n )\n}\n\nexport { Input }\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "item",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "class-variance-authority": "^0.7.1"
8
+ },
9
+ "registryDependencies": [
10
+ "separator",
11
+ "utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/item.tsx",
16
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"@/lib/utils\"\nimport { ark } from \"@ark-ui/react\"\n\nimport { Separator } from \"@/components/ui/separator\"\n\nfunction ItemGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n role=\"list\"\n data-slot=\"item-group\"\n className={cn(\n \"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {\n return <Separator data-slot=\"item-separator\" orientation=\"horizontal\" className={cn(\"my-2\", className)} {...props} />\n}\n\nconst itemVariants = cva(\n \"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted\",\n {\n variants: {\n variant: {\n default: \"border-transparent\",\n outline: \"border-border\",\n muted: \"border-transparent bg-muted/50\",\n },\n size: {\n default: \"gap-2.5 px-3 py-2.5\",\n sm: \"gap-2.5 px-3 py-2.5\",\n xs: \"gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n)\n\nfunction Item({\n className,\n variant = \"default\",\n size = \"default\",\n asChild = false,\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {\n const Comp = ark.div\n return (\n <Comp\n asChild={asChild}\n data-slot=\"item\"\n data-variant={variant}\n data-size={size}\n className={cn(itemVariants({ variant, size, className }))}\n {...props}\n />\n )\n}\n\nconst itemMediaVariants = cva(\n \"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none\",\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n icon: \"[&_svg:not([class*='size-'])]:size-4\",\n image:\n \"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nfunction ItemMedia({\n className,\n variant = \"default\",\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof itemMediaVariants>) {\n return (\n <div\n data-slot=\"item-media\"\n data-variant={variant}\n className={cn(itemMediaVariants({ variant, className }))}\n {...props}\n />\n )\n}\n\nfunction ItemContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"item-content\"\n className={cn(\n \"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction ItemTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"item-title\"\n className={cn(\n \"line-clamp-1 flex w-fit items-center gap-2 text-sm/snug font-medium underline-offset-4\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction ItemDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"item-description\"\n className={cn(\n \"line-clamp-2 text-left text-sm/normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction ItemActions({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div data-slot=\"item-actions\" className={cn(\"flex items-center gap-2\", className)} {...props} />\n}\n\nfunction ItemHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"item-header\"\n className={cn(\"flex basis-full items-center justify-between gap-2\", className)}\n {...props}\n />\n )\n}\n\nfunction ItemFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"item-footer\"\n className={cn(\"flex basis-full items-center justify-between gap-2\", className)}\n {...props}\n />\n )\n}\n\nexport {\n Item,\n ItemMedia,\n ItemContent,\n ItemActions,\n ItemGroup,\n ItemSeparator,\n ItemTitle,\n ItemDescription,\n ItemHeader,\n ItemFooter,\n}\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "json-tree-view",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1"
7
+ },
8
+ "registryDependencies": [
9
+ "utils"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/json-tree-view.tsx",
14
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { JsonTreeView as JsonTreeViewPrimitive } from \"@ark-ui/react\"\n\nfunction JsonTreeView({ className, ...props }: React.ComponentProps<typeof JsonTreeViewPrimitive.Root>) {\n return (\n <JsonTreeViewPrimitive.Root\n data-slot=\"json-tree-view\"\n className={cn(\"w-full font-mono text-xs\", className)}\n {...props}\n />\n )\n}\n\nfunction JsonTreeViewTree({ className, ...props }: React.ComponentProps<typeof JsonTreeViewPrimitive.Tree>) {\n return (\n <JsonTreeViewPrimitive.Tree\n data-slot=\"json-tree-view-tree\"\n className={cn(\n \"flex flex-col gap-px outline-none\",\n \"**:data-[part=branch-control]:flex **:data-[part=branch-control]:h-6 **:data-[part=branch-control]:cursor-default **:data-[part=branch-control]:items-center **:data-[part=branch-control]:gap-1 **:data-[part=branch-control]:rounded-md **:data-[part=branch-control]:pl-[calc(var(--depth)*(--spacing(4)))] **:data-[part=branch-control]:outline-none **:data-[part=branch-control]:hover:bg-muted **:data-[part=branch-control]:focus-visible:ring-2 **:data-[part=branch-control]:focus-visible:ring-ring/50 **:data-[part=branch-control]:focus-visible:ring-inset **:data-[part=branch-control]:data-selected:bg-muted\",\n \"**:data-[part=item]:flex **:data-[part=item]:h-6 **:data-[part=item]:cursor-default **:data-[part=item]:items-center **:data-[part=item]:gap-1 **:data-[part=item]:rounded-md **:data-[part=item]:pl-[calc(var(--depth)*(--spacing(4))+(--spacing(4)))] **:data-[part=item]:outline-none **:data-[part=item]:hover:bg-muted **:data-[part=item]:focus-visible:ring-2 **:data-[part=item]:focus-visible:ring-ring/50 **:data-[part=item]:focus-visible:ring-inset **:data-[part=item]:data-selected:bg-muted\",\n \"**:data-[part=branch-indicator]:text-muted-foreground **:data-[part=branch-indicator]:transition-transform **:data-[part=branch-indicator]:data-[state=open]:rotate-90 **:data-[part=branch-indicator]:[&_svg]:size-3.5\",\n \"**:data-[part=branch-indent-guide]:absolute **:data-[part=branch-indent-guide]:inset-y-0 **:data-[part=branch-indent-guide]:left-[calc(var(--depth)*(--spacing(4))+(--spacing(1.5)))] **:data-[part=branch-indent-guide]:w-px **:data-[part=branch-indent-guide]:bg-border\",\n \"**:data-[part=branch-content]:relative **:data-[part=branch-content]:flex **:data-[part=branch-content]:flex-col **:data-[part=branch-content]:gap-px\",\n \"**:data-[part=key]:text-foreground **:data-[part=key]:after:text-muted-foreground **:data-[part=key]:after:content-[':']\",\n \"**:data-[part=value]:data-[kind=boolean]:text-amber-600 **:data-[part=value]:data-[kind=null]:text-muted-foreground **:data-[part=value]:data-[kind=number]:text-sky-600 **:data-[part=value]:data-[kind=string]:text-emerald-600 **:data-[part=value]:data-[kind=undefined]:text-muted-foreground dark:**:data-[part=value]:data-[kind=boolean]:text-amber-400 dark:**:data-[part=value]:data-[kind=number]:text-sky-400 dark:**:data-[part=value]:data-[kind=string]:text-emerald-400\",\n className\n )}\n {...props}\n />\n )\n}\n\nexport { JsonTreeView, JsonTreeViewTree }\n"
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "kanban",
3
+ "type": "ui",
4
+ "description": "Kanban — a compositional, data-agnostic board on Pragmatic drag and drop. The consumer owns columns and cards and re-renders them from its own state; the primitive wires dragging and reports intents through `onCardMove` and `onColumnMove`. ",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "@atlaskit/pragmatic-drag-and-drop": "^3.1.0",
8
+ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.2.0",
9
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.2.0",
10
+ "lucide-react": "^1.41.0"
11
+ },
12
+ "registryDependencies": [
13
+ "badge",
14
+ "button",
15
+ "drag-preview",
16
+ "live-region",
17
+ "utils"
18
+ ],
19
+ "files": [
20
+ {
21
+ "path": "components/ui/kanban.tsx",
22
+ "content": "import * as React from \"react\"\nimport { combine } from \"@atlaskit/pragmatic-drag-and-drop/combine\"\nimport { draggable, dropTargetForElements, monitorForElements } from \"@atlaskit/pragmatic-drag-and-drop/element/adapter\"\nimport { reorder } from \"@atlaskit/pragmatic-drag-and-drop/reorder\"\nimport { autoScrollForElements } from \"@atlaskit/pragmatic-drag-and-drop-auto-scroll/element\"\nimport { attachClosestEdge, extractClosestEdge, type Edge } from \"@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge\"\nimport { getReorderDestinationIndex } from \"@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index\"\nimport { ark } from \"@ark-ui/react\"\nimport { GripVerticalIcon } from \"lucide-react\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport { cloneDragPreview } from \"@/lib/drag-preview\"\nimport { cn } from \"@/lib/utils\"\nimport { LiveRegion, useLiveRegion } from \"@/components/ui/live-region\"\n\n/**\n * Kanban — a compositional, data-agnostic board on Pragmatic drag and drop.\n * The consumer owns columns and cards and re-renders them from its own state;\n * the primitive wires dragging and reports intents through `onCardMove` and\n * `onColumnMove`. `moveCard`/`moveColumn` are optional helpers for the common\n * `{ id, cards: { id }[] }[]` shape.\n */\n\ntype KanbanCardMoveDetails = {\n cardId: string\n fromColumnId: string\n toColumnId: string\n fromIndex: number\n toIndex: number\n}\n\ntype KanbanColumnMoveDetails = {\n columnId: string\n fromIndex: number\n toIndex: number\n}\n\ntype DragData =\n | { instanceId: symbol; type: \"card\"; cardId: string; columnId: string }\n | { instanceId: symbol; type: \"column\"; columnId: string }\n\nconst CARD_TARGET = \"kanban-card-target\"\nconst COLUMN_TARGET = \"kanban-column-target\"\n\ntype KanbanDragging = { type: \"card\" | \"column\"; id: string; columnId?: string; height: number }\n/** Where a dragged card would land: before `beforeCardId`, or at the end when null. */\ntype KanbanPreview = { columnId: string; beforeCardId: string | null }\n\ntype KanbanGrabbed = { type: \"card\" | \"column\"; id: string }\n\ntype KanbanContextValue = {\n instanceId: symbol\n dragging: KanbanDragging | null\n preview: KanbanPreview | null\n /** Keyboard \"pick up\" state; arrows move the grabbed card/column. */\n grabbed: KanbanGrabbed | null\n grab: (item: KanbanGrabbed | null) => void\n moveByKey: (item: KanbanGrabbed, direction: \"up\" | \"down\" | \"left\" | \"right\") => void\n}\n\nconst focusLater = (selector: string) =>\n requestAnimationFrame(() => requestAnimationFrame(() => document.querySelector<HTMLElement>(selector)?.focus()))\n\nconst cssEscape = (value: string) => (typeof CSS !== \"undefined\" && CSS.escape ? CSS.escape(value) : value)\n\nconst KanbanContext = React.createContext<KanbanContextValue | null>(null)\n\nfunction useKanban() {\n const ctx = React.useContext(KanbanContext)\n if (!ctx) throw new Error(\"Kanban parts must be used within <Kanban>\")\n return ctx\n}\n\nconst isOwnData = (instanceId: symbol, data: Record<string | symbol, unknown>): data is DragData =>\n data.instanceId === instanceId && (data.type === \"card\" || data.type === \"column\")\n\nfunction cardsOf(columnEl: Element) {\n return Array.from(columnEl.querySelectorAll<HTMLElement>(\":scope [data-slot=kanban-card]\"))\n}\n\ntype DropTargetLike = { data: Record<string | symbol, unknown> }\n\n/** Resolve a card drag against the current drop targets into a concrete destination. */\nfunction resolveCardDrop(root: HTMLElement, data: Extract<DragData, { type: \"card\" }>, targets: DropTargetLike[]) {\n const columnTarget = targets.find((t) => t.data[COLUMN_TARGET] === true)\n if (!columnTarget) return null\n const toColumnId = String(columnTarget.data.columnId)\n const columnEls = Array.from(root.querySelectorAll<HTMLElement>(\"[data-slot=kanban-column]\"))\n const fromColumnEl = columnEls.find((el) => el.dataset.value === data.columnId)\n const toColumnEl = columnEls.find((el) => el.dataset.value === toColumnId)\n if (!fromColumnEl || !toColumnEl) return null\n const fromIndex = cardsOf(fromColumnEl).findIndex((el) => el.dataset.value === data.cardId)\n const toCards = cardsOf(toColumnEl)\n const cardTarget = targets.find((t) => t.data[CARD_TARGET] === true)\n const sameColumn = data.columnId === toColumnId\n\n let toIndex: number\n if (cardTarget && String(cardTarget.data.cardId) !== data.cardId) {\n const indexOfTarget = toCards.findIndex((el) => el.dataset.value === String(cardTarget.data.cardId))\n const edge = extractClosestEdge(cardTarget.data)\n toIndex = sameColumn\n ? getReorderDestinationIndex({\n startIndex: fromIndex,\n indexOfTarget,\n closestEdgeOfTarget: edge,\n axis: \"vertical\",\n })\n : edge === \"bottom\"\n ? indexOfTarget + 1\n : indexOfTarget\n } else if (cardTarget) {\n // Hovering the dragged card itself: no move.\n toIndex = fromIndex\n } else {\n toIndex = sameColumn ? toCards.length - 1 : toCards.length\n }\n const noop = sameColumn && toIndex === fromIndex\n const remaining = toCards.filter((el) => el.dataset.value !== data.cardId)\n const beforeCardId = remaining[toIndex]?.dataset.value ?? null\n return { toColumnId, toIndex, fromIndex, sameColumn, noop, beforeCardId }\n}\n\nfunction Kanban({\n onCardMove,\n onColumnMove,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n onCardMove?: (details: KanbanCardMoveDetails) => void\n onColumnMove?: (details: KanbanColumnMoveDetails) => void\n}) {\n const [instanceId] = React.useState(() => Symbol(\"kanban\"))\n const [dragging, setDragging] = React.useState<KanbanDragging | null>(null)\n const [preview, setPreviewState] = React.useState<KanbanPreview | null>(null)\n const [grabbed, setGrabbed] = React.useState<KanbanGrabbed | null>(null)\n const { message: announcement, announce: setAnnouncement } = useLiveRegion()\n const previewKey = React.useRef<string | null>(null)\n const ref = React.useRef<HTMLDivElement>(null)\n const callbacks = React.useRef({ onCardMove, onColumnMove })\n callbacks.current = { onCardMove, onColumnMove }\n\n const grab = React.useCallback(\n (item: KanbanGrabbed | null) => {\n setGrabbed(item)\n setAnnouncement(\n item\n ? `Picked up ${item.type} ${item.id}. Use the arrow keys to move, Space or Enter to drop, Escape to cancel.`\n : \"Dropped.\"\n )\n },\n [setAnnouncement]\n )\n\n const moveByKey = React.useCallback(\n (item: KanbanGrabbed, direction: \"up\" | \"down\" | \"left\" | \"right\") => {\n const root = ref.current\n if (!root) return\n const columnEls = Array.from(root.querySelectorAll<HTMLElement>(\"[data-slot=kanban-column]\"))\n const columnIds = columnEls.map((el) => el.dataset.value ?? \"\")\n const columnName = (el: HTMLElement) =>\n el.querySelector(\"[data-slot=kanban-column-title]\")?.textContent ?? el.dataset.value\n\n if (item.type === \"column\") {\n const fromIndex = columnIds.indexOf(item.id)\n const toIndex = direction === \"left\" ? fromIndex - 1 : direction === \"right\" ? fromIndex + 1 : fromIndex\n if (fromIndex < 0 || toIndex === fromIndex || toIndex < 0 || toIndex >= columnIds.length) return\n callbacks.current.onColumnMove?.({ columnId: item.id, fromIndex, toIndex })\n setAnnouncement(`Moved column to position ${toIndex + 1} of ${columnIds.length}.`)\n focusLater(`[data-slot=kanban-column][data-value=\"${cssEscape(item.id)}\"] [data-slot=kanban-column-handle]`)\n return\n }\n\n const fromColumnEl = columnEls.find((el) => cardsOf(el).some((c) => c.dataset.value === item.id))\n if (!fromColumnEl) return\n const fromColumnId = fromColumnEl.dataset.value ?? \"\"\n const fromCards = cardsOf(fromColumnEl)\n const fromIndex = fromCards.findIndex((c) => c.dataset.value === item.id)\n let toColumnId = fromColumnId\n let toIndex = fromIndex\n if (direction === \"up\" || direction === \"down\") {\n toIndex = direction === \"up\" ? fromIndex - 1 : fromIndex + 1\n if (toIndex < 0 || toIndex >= fromCards.length) return\n } else {\n const ci = columnIds.indexOf(fromColumnId)\n const targetEl = columnEls[direction === \"left\" ? ci - 1 : ci + 1]\n if (!targetEl) return\n toColumnId = targetEl.dataset.value ?? \"\"\n toIndex = Math.min(fromIndex, cardsOf(targetEl).length)\n }\n callbacks.current.onCardMove?.({ cardId: item.id, fromColumnId, toColumnId, fromIndex, toIndex })\n const targetEl = columnEls[columnIds.indexOf(toColumnId)]\n const count = toColumnId === fromColumnId ? fromCards.length : cardsOf(targetEl).length + 1\n setAnnouncement(`Moved to ${columnName(targetEl)}, position ${toIndex + 1} of ${count}.`)\n focusLater(`[data-slot=kanban-card][data-value=\"${cssEscape(item.id)}\"]`)\n },\n [setAnnouncement]\n )\n\n React.useEffect(() => {\n const root = ref.current\n if (!root) return\n const setPreview = (next: KanbanPreview | null) => {\n const key = next ? `${next.columnId}::${next.beforeCardId ?? \"\"}` : null\n if (key === previewKey.current) return\n previewKey.current = key\n setPreviewState(next)\n }\n const updatePreview = (data: DragData, targets: DropTargetLike[]) => {\n if (data.type !== \"card\") return\n const resolved = resolveCardDrop(root, data, targets)\n setPreview(\n resolved && !resolved.noop ? { columnId: resolved.toColumnId, beforeCardId: resolved.beforeCardId } : null\n )\n }\n return monitorForElements({\n canMonitor: ({ source }) => isOwnData(instanceId, source.data),\n onDragStart: ({ source }) => {\n const data = source.data as DragData\n setDragging({\n type: data.type,\n id: data.type === \"card\" ? data.cardId : data.columnId,\n columnId: data.type === \"card\" ? data.columnId : undefined,\n height: source.element.getBoundingClientRect().height,\n })\n },\n onDrag: ({ source, location }) => updatePreview(source.data as DragData, location.current.dropTargets),\n onDropTargetChange: ({ source, location }) =>\n updatePreview(source.data as DragData, location.current.dropTargets),\n onDrop: ({ source, location }) => {\n setDragging(null)\n setPreview(null)\n const data = source.data as DragData\n const targets = location.current.dropTargets\n const columnEls = Array.from(root.querySelectorAll<HTMLElement>(\"[data-slot=kanban-column]\"))\n const columnIds = columnEls.map((el) => el.dataset.value ?? \"\")\n\n if (data.type === \"column\") {\n const target = targets.find((t) => t.data[COLUMN_TARGET] === true)\n if (!target) return\n const targetId = String(target.data.columnId)\n const fromIndex = columnIds.indexOf(data.columnId)\n const indexOfTarget = columnIds.indexOf(targetId)\n if (fromIndex < 0 || indexOfTarget < 0) return\n const toIndex = getReorderDestinationIndex({\n startIndex: fromIndex,\n indexOfTarget,\n closestEdgeOfTarget: extractClosestEdge(target.data),\n axis: \"horizontal\",\n })\n if (toIndex === fromIndex) return\n callbacks.current.onColumnMove?.({ columnId: data.columnId, fromIndex, toIndex })\n return\n }\n\n const resolved = resolveCardDrop(root, data, targets)\n if (!resolved || resolved.noop) return\n callbacks.current.onCardMove?.({\n cardId: data.cardId,\n fromColumnId: data.columnId,\n toColumnId: resolved.toColumnId,\n fromIndex: resolved.fromIndex,\n toIndex: resolved.toIndex,\n })\n },\n })\n }, [instanceId])\n\n const ctx = React.useMemo(\n () => ({ instanceId, dragging, preview, grabbed, grab, moveByKey }),\n [instanceId, dragging, preview, grabbed, grab, moveByKey]\n )\n return (\n <KanbanContext.Provider value={ctx}>\n <div\n ref={ref}\n data-slot=\"kanban\"\n data-dragging={dragging ? dragging.type : undefined}\n data-grabbed={grabbed ? grabbed.type : undefined}\n className={cn(\"flex min-h-0 flex-1 flex-col\", className)}\n {...props}\n >\n {children}\n <LiveRegion data-slot=\"kanban-live-region\" message={announcement} />\n </div>\n </KanbanContext.Provider>\n )\n}\n\n/** Keyboard handling shared by cards and column handles. */\nfunction useKanbanKeyboard(item: KanbanGrabbed) {\n const { grabbed, grab, moveByKey } = useKanban()\n const isGrabbed = grabbed?.type === item.type && grabbed.id === item.id\n const onKeyDown = (event: React.KeyboardEvent) => {\n const key = event.key\n if (key === \" \" || key === \"Enter\") {\n event.preventDefault()\n grab(isGrabbed ? null : item)\n return\n }\n if (!isGrabbed) return\n if (key === \"Escape\") {\n event.preventDefault()\n grab(null)\n return\n }\n const direction = { ArrowUp: \"up\", ArrowDown: \"down\", ArrowLeft: \"left\", ArrowRight: \"right\" }[key] as\n \"up\" | \"down\" | \"left\" | \"right\" | undefined\n if (!direction) return\n event.preventDefault()\n moveByKey(item, direction)\n }\n return { isGrabbed, onKeyDown, onBlur: () => isGrabbed && grab(null) }\n}\n\n/* -------------------------------------------------------------------------- */\n/* Board */\n/* -------------------------------------------------------------------------- */\n\nfunction KanbanBoard({ className, ...props }: React.ComponentProps<\"div\">) {\n const { instanceId } = useKanban()\n const ref = React.useRef<HTMLDivElement>(null)\n React.useEffect(() => {\n const el = ref.current\n if (!el) return\n return autoScrollForElements({\n element: el,\n canScroll: ({ source }) => isOwnData(instanceId, source.data),\n })\n }, [instanceId])\n return (\n <div\n ref={ref}\n data-slot=\"kanban-board\"\n className={cn(\"flex min-h-0 flex-1 items-start gap-4 overflow-x-auto p-1 pb-4\", className)}\n {...props}\n />\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Column */\n/* -------------------------------------------------------------------------- */\n\ntype ColumnContextValue = {\n value: string\n handleRef: React.RefObject<HTMLElement | null>\n headerRef: React.RefObject<HTMLElement | null>\n isOver: boolean\n isDragging: boolean\n}\n\nconst ColumnContext = React.createContext<ColumnContextValue | null>(null)\n\nfunction useKanbanColumn() {\n const ctx = React.useContext(ColumnContext)\n if (!ctx) throw new Error(\"Kanban column parts must be used within <KanbanColumn>\")\n return ctx\n}\n\nfunction KanbanColumn({\n value,\n draggable: isDraggable = true,\n className,\n children,\n ...props\n}: React.ComponentProps<\"section\"> & {\n value: string\n /** Allow the column itself to be reordered (drag by its handle or header). */\n draggable?: boolean\n}) {\n const { instanceId, dragging, preview } = useKanban()\n const ref = React.useRef<HTMLElement>(null)\n const handleRef = React.useRef<HTMLElement | null>(null)\n const headerRef = React.useRef<HTMLElement | null>(null)\n const [isOver, setIsOver] = React.useState(false)\n const [isDragging, setIsDragging] = React.useState(false)\n const [closestEdge, setClosestEdge] = React.useState<Edge | null>(null)\n\n React.useEffect(() => {\n const el = ref.current\n if (!el) return\n const cleanups = [\n dropTargetForElements({\n element: el,\n canDrop: ({ source }) => isOwnData(instanceId, source.data),\n getIsSticky: () => true,\n getData: ({ source, input, element }) => {\n const base = { [COLUMN_TARGET]: true, columnId: value }\n if ((source.data as DragData).type === \"column\") {\n return attachClosestEdge(base, { element, input, allowedEdges: [\"left\", \"right\"] })\n }\n return base\n },\n onDragEnter: ({ source, self }) => {\n const data = source.data as DragData\n if (data.type === \"card\") setIsOver(true)\n else if (data.columnId !== value) setClosestEdge(extractClosestEdge(self.data))\n },\n onDrag: ({ source, self }) => {\n const data = source.data as DragData\n if (data.type === \"column\" && data.columnId !== value) {\n const edge = extractClosestEdge(self.data)\n setClosestEdge((prev) => (prev === edge ? prev : edge))\n }\n },\n onDragLeave: () => {\n setIsOver(false)\n setClosestEdge(null)\n },\n onDrop: () => {\n setIsOver(false)\n setClosestEdge(null)\n },\n }),\n ]\n if (isDraggable) {\n cleanups.push(\n draggable({\n element: el,\n dragHandle: handleRef.current ?? headerRef.current ?? undefined,\n getInitialData: (): DragData => ({ instanceId, type: \"column\", columnId: value }),\n onGenerateDragPreview: (args) => cloneDragPreview(args),\n onDragStart: () => setIsDragging(true),\n onDrop: () => setIsDragging(false),\n })\n )\n }\n return combine(...cleanups)\n }, [instanceId, value, isDraggable])\n\n const ctx = React.useMemo(() => ({ value, handleRef, headerRef, isOver, isDragging }), [value, isOver, isDragging])\n return (\n <ColumnContext.Provider value={ctx}>\n <section\n ref={ref}\n data-slot=\"kanban-column\"\n data-value={value}\n data-over={isOver && dragging?.type === \"card\" ? \"\" : undefined}\n data-target={preview?.columnId === value ? \"\" : undefined}\n data-dragging={isDragging ? \"\" : undefined}\n data-edge={closestEdge ?? undefined}\n className={cn(\n \"group/kanban-column relative flex max-h-full w-72 shrink-0 flex-col rounded-xl border bg-muted/40 transition-colors data-dragging:opacity-40 data-target:border-primary/30\",\n className\n )}\n {...props}\n >\n {children}\n {closestEdge && <KanbanDropIndicator edge={closestEdge} />}\n </section>\n </ColumnContext.Provider>\n )\n}\n\nfunction KanbanColumnHeader({ className, ...props }: React.ComponentProps<\"header\">) {\n const { headerRef } = useKanbanColumn()\n return (\n <header\n ref={headerRef as React.RefObject<HTMLElement>}\n data-slot=\"kanban-column-header\"\n className={cn(\"flex items-center gap-2 px-3 py-2.5\", className)}\n {...props}\n />\n )\n}\n\nfunction KanbanColumnHandle({\n className,\n children,\n onKeyDown,\n onBlur,\n asChild,\n ...props\n}: React.ComponentProps<typeof ark.button>) {\n const { handleRef, value } = useKanbanColumn()\n const keyboard = useKanbanKeyboard({ type: \"column\", id: value })\n return (\n <ark.button\n asChild={asChild}\n ref={handleRef as React.RefObject<HTMLButtonElement>}\n type=\"button\"\n data-slot=\"kanban-column-handle\"\n data-grabbed={keyboard.isGrabbed ? \"\" : undefined}\n aria-label=\"Drag column\"\n aria-pressed={keyboard.isGrabbed}\n aria-describedby={undefined}\n onKeyDown={(event) => {\n onKeyDown?.(event)\n if (!event.defaultPrevented) keyboard.onKeyDown(event)\n }}\n onBlur={(event) => {\n onBlur?.(event)\n keyboard.onBlur()\n }}\n className={cn(\n \"-ms-1 flex size-6 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 active:cursor-grabbing data-grabbed:bg-primary data-grabbed:text-primary-foreground [&_svg]:size-4\",\n className\n )}\n {...props}\n >\n {asChild ? children : (children ?? <GripVerticalIcon />)}\n </ark.button>\n )\n}\n\nfunction KanbanColumnTitle({ className, ...props }: React.ComponentProps<\"h3\">) {\n return <h3 data-slot=\"kanban-column-title\" className={cn(\"truncate text-sm font-semibold\", className)} {...props} />\n}\n\nfunction KanbanColumnCount({ className, ...props }: React.ComponentProps<typeof Badge>) {\n return (\n <Badge\n data-slot=\"kanban-column-count\"\n variant=\"secondary\"\n className={cn(\"h-5 min-w-5 justify-center px-1.5 tabular-nums\", className)}\n {...props}\n />\n )\n}\n\nfunction KanbanColumnActions({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div data-slot=\"kanban-column-actions\" className={cn(\"ms-auto flex items-center gap-1\", className)} {...props} />\n )\n}\n\n/** The scrolling list of cards. Renders the landing slot at the end when a card would be appended. */\nfunction KanbanColumnContent({ className, children, ...props }: React.ComponentProps<\"div\">) {\n const { instanceId, dragging, preview } = useKanban()\n const column = useKanbanColumn()\n const ref = React.useRef<HTMLDivElement>(null)\n React.useEffect(() => {\n const el = ref.current\n if (!el) return\n return autoScrollForElements({\n element: el,\n canScroll: ({ source }) => isOwnData(instanceId, source.data),\n })\n }, [instanceId])\n return (\n <div\n ref={ref}\n data-slot=\"kanban-column-content\"\n className={cn(\n \"no-scrollbar flex min-h-16 flex-1 flex-col gap-(--kanban-gap) overflow-y-auto px-2 pb-2 [--kanban-gap:0.5rem]\",\n className\n )}\n {...props}\n >\n {children}\n {dragging?.type === \"card\" && preview?.columnId === column.value && preview.beforeCardId === null && (\n <KanbanDropSlot height={dragging.height} />\n )}\n </div>\n )\n}\n\nfunction KanbanColumnFooter({ className, ...props }: React.ComponentProps<\"footer\">) {\n return <footer data-slot=\"kanban-column-footer\" className={cn(\"flex items-center px-2 pb-2\", className)} {...props} />\n}\n\n/** A ghost-style full-width trigger for the footer, e.g. \"Add card\". Polymorphic via `asChild`. */\nfunction KanbanAddTrigger({\n className,\n variant = \"ghost\",\n size = \"sm\",\n ...props\n}: React.ComponentProps<typeof Button>) {\n return (\n <Button\n data-slot=\"kanban-add-trigger\"\n variant={variant}\n size={size}\n className={cn(\"w-full justify-start text-muted-foreground hover:text-foreground\", className)}\n {...props}\n />\n )\n}\n\nfunction KanbanEmpty({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"kanban-empty\"\n className={cn(\n \"flex flex-1 items-center justify-center rounded-lg border border-dashed px-3 py-6 text-center text-xs text-muted-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Card */\n/* -------------------------------------------------------------------------- */\n\ntype CardContextValue = { handleRef: React.RefObject<HTMLElement | null>; value: string }\nconst CardContext = React.createContext<CardContextValue | null>(null)\n\nfunction KanbanCard({\n value,\n draggable: isDraggable = true,\n className,\n children,\n onKeyDown,\n onBlur,\n ...props\n}: React.ComponentProps<\"article\"> & { value: string; draggable?: boolean }) {\n const { instanceId, dragging, preview } = useKanban()\n const column = useKanbanColumn()\n const keyboard = useKanbanKeyboard({ type: \"card\", id: value })\n const ref = React.useRef<HTMLElement>(null)\n const handleRef = React.useRef<HTMLElement | null>(null)\n const [isDragging, setIsDragging] = React.useState(false)\n const isSlotBefore = dragging?.type === \"card\" && preview?.columnId === column.value && preview.beforeCardId === value\n // The dragged card stays as an outlined placeholder until a landing spot exists, then collapses.\n const isCollapsed = isDragging && preview !== null\n\n React.useEffect(() => {\n const el = ref.current\n if (!el) return\n const cleanups = [\n dropTargetForElements({\n element: el,\n canDrop: ({ source }) => isOwnData(instanceId, source.data) && (source.data as DragData).type === \"card\",\n getIsSticky: () => true,\n getData: ({ input, element }) =>\n attachClosestEdge(\n { [CARD_TARGET]: true, cardId: value, columnId: column.value },\n {\n element,\n input,\n allowedEdges: [\"top\", \"bottom\"],\n }\n ),\n }),\n ]\n if (isDraggable) {\n cleanups.push(\n draggable({\n element: el,\n dragHandle: handleRef.current ?? undefined,\n getInitialData: (): DragData => ({ instanceId, type: \"card\", cardId: value, columnId: column.value }),\n onGenerateDragPreview: (args) => cloneDragPreview(args),\n onDragStart: () => setIsDragging(true),\n onDrop: () => setIsDragging(false),\n })\n )\n }\n return combine(...cleanups)\n }, [instanceId, value, column.value, isDraggable])\n\n const ctx = React.useMemo(() => ({ handleRef, value }), [value])\n return (\n <CardContext.Provider value={ctx}>\n {isSlotBefore && dragging && <KanbanDropSlot height={dragging.height} />}\n <article\n ref={ref}\n data-slot=\"kanban-card\"\n data-value={value}\n data-dragging={isDragging ? \"\" : undefined}\n data-collapsed={isCollapsed ? \"\" : undefined}\n data-grabbed={keyboard.isGrabbed ? \"\" : undefined}\n tabIndex={isDraggable ? 0 : undefined}\n aria-roledescription={isDraggable ? \"draggable card\" : undefined}\n onKeyDown={(event) => {\n onKeyDown?.(event)\n if (!event.defaultPrevented && event.target === event.currentTarget) keyboard.onKeyDown(event)\n }}\n onBlur={(event) => {\n onBlur?.(event)\n if (event.target === event.currentTarget) keyboard.onBlur()\n }}\n className={cn(\n \"group/kanban-card relative flex cursor-grab flex-col gap-2 rounded-lg border bg-card p-3 text-sm text-card-foreground shadow-xs transition-shadow outline-none hover:shadow-sm focus-visible:ring-3 focus-visible:ring-ring/50 active:cursor-grabbing data-grabbed:border-primary data-grabbed:ring-3 data-grabbed:ring-primary/30\",\n \"data-dragging:border-dashed data-dragging:border-primary/40 data-dragging:bg-primary/5 data-dragging:shadow-none data-dragging:*:invisible\",\n // Collapse (not `display:none`): removing the dragged element from layout cancels a native drag in Chrome.\n \"data-collapsed:pointer-events-none data-collapsed:-mb-(--kanban-gap) data-collapsed:h-0 data-collapsed:min-h-0 data-collapsed:overflow-hidden data-collapsed:border-0 data-collapsed:p-0 data-collapsed:opacity-0\",\n className\n )}\n {...props}\n >\n {children}\n </article>\n </CardContext.Provider>\n )\n}\n\n/** Optional explicit drag handle; without it the whole card is the handle. */\nfunction KanbanCardHandle({\n className,\n children,\n onKeyDown,\n onBlur,\n asChild,\n ...props\n}: React.ComponentProps<typeof ark.button>) {\n const ctx = React.useContext(CardContext)\n const keyboard = useKanbanKeyboard({ type: \"card\", id: ctx?.value ?? \"\" })\n return (\n <ark.button\n asChild={asChild}\n ref={(el: HTMLElement | null) => {\n if (ctx) ctx.handleRef.current = el\n }}\n type=\"button\"\n data-slot=\"kanban-card-handle\"\n data-grabbed={keyboard.isGrabbed ? \"\" : undefined}\n aria-label=\"Drag card\"\n aria-pressed={keyboard.isGrabbed}\n onKeyDown={(event) => {\n onKeyDown?.(event)\n if (!event.defaultPrevented) {\n keyboard.onKeyDown(event)\n event.stopPropagation()\n }\n }}\n onBlur={(event) => {\n onBlur?.(event)\n keyboard.onBlur()\n }}\n className={cn(\n \"flex size-6 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 active:cursor-grabbing data-grabbed:bg-primary data-grabbed:text-primary-foreground [&_svg]:size-4\",\n className\n )}\n {...props}\n >\n {asChild ? children : (children ?? <GripVerticalIcon />)}\n </ark.button>\n )\n}\n\nfunction KanbanCardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"kanban-card-header\"\n className={cn(\"flex items-start justify-between gap-2\", className)}\n {...props}\n />\n )\n}\n\nfunction KanbanCardTitle({ className, ...props }: React.ComponentProps<\"p\">) {\n return <p data-slot=\"kanban-card-title\" className={cn(\"leading-snug font-medium\", className)} {...props} />\n}\n\nfunction KanbanCardDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"kanban-card-description\"\n className={cn(\"line-clamp-3 text-xs text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\nfunction KanbanCardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"kanban-card-footer\"\n className={cn(\"flex items-center gap-2 text-xs text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Drop slot + indicator */\n/* -------------------------------------------------------------------------- */\n\n/** An outlined placeholder the size of the dragged card, marking where it will land. */\nfunction KanbanDropSlot({ height, className, style, ...props }: React.ComponentProps<\"div\"> & { height?: number }) {\n return (\n <div\n data-slot=\"kanban-drop-slot\"\n aria-hidden\n style={{ height, ...style }}\n className={cn(\n \"shrink-0 rounded-lg border-2 border-dashed border-primary/40 bg-primary/5\",\n height === undefined && \"min-h-16\",\n className\n )}\n {...props}\n />\n )\n}\n\n/** A line on the given edge of its (relative) parent, with a terminal dot. */\nfunction KanbanDropIndicator({\n edge,\n gap = \"0.5rem\",\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { edge: Edge; gap?: string }) {\n const horizontal = edge === \"top\" || edge === \"bottom\"\n return (\n <div\n data-slot=\"kanban-drop-indicator\"\n data-edge={edge}\n aria-hidden\n style={{ [\"--kanban-gap\" as string]: gap }}\n className={cn(\n \"pointer-events-none absolute z-10 bg-primary\",\n \"before:absolute before:size-2 before:rounded-full before:border-2 before:border-primary before:bg-background\",\n horizontal\n ? \"inset-x-0 h-0.5 before:top-1/2 before:-left-1 before:-translate-y-1/2\"\n : \"inset-y-0 w-0.5 before:-top-1 before:left-1/2 before:-translate-x-1/2\",\n edge === \"top\" && \"top-[calc(-1*var(--kanban-gap)/2-1px)]\",\n edge === \"bottom\" && \"bottom-[calc(-1*var(--kanban-gap)/2-1px)]\",\n edge === \"left\" && \"left-[calc(-1*var(--kanban-gap)/2-1px)]\",\n edge === \"right\" && \"right-[calc(-1*var(--kanban-gap)/2-1px)]\",\n className\n )}\n {...props}\n />\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Helpers */\n/* -------------------------------------------------------------------------- */\n\ntype KanbanColumnLike<TCard extends { id: string }> = { id: string; cards: TCard[] }\n\n/** Apply an `onCardMove` intent to a `{ id, cards }[]` state shape. */\nfunction moveCard<TCard extends { id: string }, TColumn extends KanbanColumnLike<TCard>>(\n columns: TColumn[],\n { cardId, fromColumnId, toColumnId, toIndex }: KanbanCardMoveDetails\n): TColumn[] {\n const from = columns.find((c) => c.id === fromColumnId)\n const card = from?.cards.find((c) => c.id === cardId)\n if (!from || !card) return columns\n return columns.map((column) => {\n if (column.id === fromColumnId && column.id === toColumnId) {\n const startIndex = column.cards.indexOf(card)\n return { ...column, cards: reorder({ list: column.cards, startIndex, finishIndex: toIndex }) }\n }\n if (column.id === fromColumnId) return { ...column, cards: column.cards.filter((c) => c !== card) }\n if (column.id === toColumnId) {\n const cards = [...column.cards]\n cards.splice(toIndex, 0, card)\n return { ...column, cards }\n }\n return column\n })\n}\n\n/** Apply an `onColumnMove` intent to a column array. */\nfunction moveColumn<TColumn extends { id: string }>(\n columns: TColumn[],\n { fromIndex, toIndex }: KanbanColumnMoveDetails\n): TColumn[] {\n return reorder({ list: columns, startIndex: fromIndex, finishIndex: toIndex })\n}\n\nexport {\n Kanban,\n KanbanAddTrigger,\n KanbanBoard,\n KanbanCard,\n KanbanCardDescription,\n KanbanCardFooter,\n KanbanCardHandle,\n KanbanCardHeader,\n KanbanCardTitle,\n KanbanColumn,\n KanbanColumnActions,\n KanbanColumnContent,\n KanbanColumnCount,\n KanbanColumnFooter,\n KanbanColumnHandle,\n KanbanColumnHeader,\n KanbanColumnTitle,\n KanbanDropIndicator,\n KanbanDropSlot,\n KanbanEmpty,\n moveCard,\n moveColumn,\n useKanban,\n useKanbanColumn,\n type KanbanCardMoveDetails,\n type KanbanColumnMoveDetails,\n}\n"
23
+ }
24
+ ],
25
+ "docs": "## Kanban\n`src/components/ui/kanban.tsx` is a compositional, data-agnostic board on\n`@atlaskit/pragmatic-drag-and-drop` (+ `-hitbox` closest-edge/reorder index, `-auto-scroll`).\nThe consumer owns columns/cards state; `Kanban` only wires dragging and reports intents via\n`onCardMove({ cardId, fromColumnId, toColumnId, fromIndex, toIndex })` and\n`onColumnMove({ columnId, fromIndex, toIndex })`. `moveCard`/`moveColumn` apply those to a\n`{ id, cards: { id }[] }[]` shape. Parts: `KanbanBoard` (horizontal scroller, auto-scroll),\n`KanbanColumn` (`value`, drop target for cards and columns, `draggable` by its\n`KanbanColumnHandle` or header), `KanbanColumnHeader/Title/Count/Actions/Content/Footer`,\n`KanbanAddTrigger`, `KanbanEmpty`, `KanbanCard` (`value`, draggable + drop target with\ntop/bottom edges, optional `KanbanCardHandle`), `KanbanCardHeader/Title/Description/Footer`,\n`KanbanDropSlot` (outlined placeholder sized like the dragged card; the root computes the\nlanding spot live via `preview` in context, cards render the slot before themselves and\n`KanbanColumnContent` renders it at the end) and `KanbanDropIndicator` (edge line, used for\ncolumn reordering). While a landing spot exists the source card collapses (`data-collapsed`,\nheight 0 rather than `display:none`, which would cancel Chrome's native drag); with no\nlanding spot it stays in place as a dashed outline. State via `data-dragging`, `data-over`,\n`data-target`, `data-edge` on the root/column/card. Indices are derived from DOM order at drop time, so cards need no index\nprop. Keyboard reordering is not provided (Pragmatic leaves it to the consumer).\nKeyboard: cards (and `KanbanCardHandle`/`KanbanColumnHandle`) are focusable; Space/Enter\npicks up (`data-grabbed`, `aria-pressed`), arrows move within/between columns via the same\n`onCardMove`/`onColumnMove` intents, Space/Enter drops, Escape cancels, focus follows the\nmoved item, and a visually hidden live region announces each step. Native drag previews are\ncrisp clones via `cloneDragPreview` in `src/lib/drag-preview.ts`.\n`src/demo/kanban/` is the reference (tasks data, route `/projects/board`, `AppShellMain fixed`)."
26
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "kbd",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {},
6
+ "registryDependencies": [
7
+ "utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "components/ui/kbd.tsx",
12
+ "content": "import { cn } from \"@/lib/utils\"\n\nfunction Kbd({ className, ...props }: React.ComponentProps<\"kbd\">) {\n return (\n <kbd\n data-slot=\"kbd\"\n className={cn(\n \"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction KbdGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return <kbd data-slot=\"kbd-group\" className={cn(\"inline-flex items-center gap-1\", className)} {...props} />\n}\n\nexport { Kbd, KbdGroup }\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "label",
3
+ "type": "ui",
4
+ "description": "Ark UI has no standalone Label primitive (its labels live inside each component's anatomy). This mirrors Radix's: a native `label` that ignores double-click text selection.",
5
+ "dependencies": {},
6
+ "registryDependencies": [
7
+ "utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "components/ui/label.tsx",
12
+ "content": "import * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Ark UI has no standalone Label primitive (its labels live inside each\n * component's anatomy). This mirrors Radix's: a native `label` that ignores\n * double-click text selection.\n */\nfunction Label({ className, onMouseDown, ...props }: React.ComponentProps<\"label\">) {\n return (\n <label\n data-slot=\"label\"\n className={cn(\n \"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n className\n )}\n onMouseDown={(event) => {\n // Only prevent text selection if clicking inside the label itself\n const target = event.target as HTMLElement\n if (target.closest(\"button, input, select, textarea\")) return\n onMouseDown?.(event)\n // Prevent text selection when double clicking label\n if (!event.defaultPrevented && event.detail > 1) event.preventDefault()\n }}\n {...props}\n />\n )\n}\n\nexport { Label }\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "live-region",
3
+ "type": "ui",
4
+ "description": "Shared screen-reader announcer. `announce` re-fires even when the same text is announced twice, and `clearAfter` (ms) empties the region so stale text is not re-read when a user lands on it.",
5
+ "dependencies": {},
6
+ "registryDependencies": [
7
+ "utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "components/ui/live-region.tsx",
12
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Shared screen-reader announcer. `announce` re-fires even when the same text is\n * announced twice, and `clearAfter` (ms) empties the region so stale text is not\n * re-read when a user lands on it.\n */\nfunction useLiveRegion(options: { clearAfter?: number } = {}) {\n const { clearAfter } = options\n const [message, setMessage] = React.useState(\"\")\n const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const announce = React.useCallback(\n (text: string) => {\n setMessage(\"\")\n queueMicrotask(() => setMessage(text))\n if (timer.current) clearTimeout(timer.current)\n if (clearAfter) timer.current = setTimeout(() => setMessage(\"\"), clearAfter)\n },\n [clearAfter]\n )\n React.useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current)\n },\n []\n )\n return { message, announce }\n}\n\n/** Visually hidden `role=\"status\"` region. Pair with `useLiveRegion`. */\nfunction LiveRegion({\n message,\n assertive = false,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { message: string; assertive?: boolean }) {\n return (\n <div\n data-slot=\"live-region\"\n role={assertive ? \"alert\" : \"status\"}\n aria-live={assertive ? \"assertive\" : \"polite\"}\n aria-atomic=\"true\"\n className={cn(\"sr-only\", className)}\n {...props}\n >\n {message}\n </div>\n )\n}\n\nexport { LiveRegion, useLiveRegion }\n"
13
+ }
14
+ ],
15
+ "docs": "## Shared infrastructure\n- `src/lib/controllable.ts` `useControllable(controlled, default, onChange)` is the one\n controllable-state hook (updater functions accepted; `onChange` fires in both modes, not on\n no-op sets). Every primitive and demo composition uses it (chat, menubar, comment thread, gantt,\n scheduler, data grid, query builder, tree select, cascader, transfer list, the table engine,\n apps view, settings layout). Never hand-roll `valueProp ?? internal` again.\n- `src/components/ui/live-region.tsx`: `useLiveRegion({ clearAfter })` → `{ message, announce }`\n (re-fires identical text) and `<LiveRegion message= assertive? />` (sr-only `role=\"status\"`).\n Kanban, canvas, gantt, scheduler, node graph, data grid, data table bulk actions, and transfer\n list all announce through it; a primitive renders its own `LiveRegion` (with its `data-slot`)\n so it works without any provider.\n- `src/components/ui/hotkeys.tsx`: `HotkeysProvider` (one document keydown listener; chords like\n `mod+k` where `mod` is ⌘ on Mac and Ctrl elsewhere, sequences like `g d` with a 1s window,\n ignores typing in inputs unless `allowInInput`, built-in `?` toggles the dialog;\n `dialogHotkey={null}` disables), `useHotkey(hotkey, handler, { label, group, enabled,\n allowInInput })` registers for the component's lifetime (labelled ones appear in the dialog),\n `useHotkeys()` (grouped list), `HotkeysDialog` (the cheat sheet on `Dialog`; `useHotkeysDialog()`\n opens it), `HotkeyKbd hotkey=` (platform-aware `Kbd` caps, \"then\" between sequence steps),\n `formatHotkey`/`parseHotkey`. The shell demo wraps everything in the provider with\n `AppShell searchShortcut={false}` and registers ⌘K, ⌘J (theme), and `g` + letter navigation in\n `ShellHotkeys`; the user menu has a \"Keyboard shortcuts\" item. Pages register their own with\n `useHotkey` and a `group`."
16
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "marker",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "class-variance-authority": "^0.7.1"
8
+ },
9
+ "registryDependencies": [
10
+ "utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/marker.tsx",
15
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"@/lib/utils\"\nimport { ark } from \"@ark-ui/react\"\n\nconst markerVariants = cva(\n \"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground\",\n {\n variants: {\n variant: {\n default: \"\",\n separator:\n \"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border\",\n border: \"border-b border-border pb-2\",\n },\n },\n }\n)\n\nfunction Marker({\n className,\n variant = \"default\",\n asChild = false,\n ...props\n}: React.ComponentProps<\"div\"> &\n VariantProps<typeof markerVariants> & {\n asChild?: boolean\n }) {\n const Comp = ark.div\n\n return (\n <Comp\n asChild={asChild}\n data-slot=\"marker\"\n data-variant={variant}\n className={cn(markerVariants({ variant, className }))}\n {...props}\n />\n )\n}\n\nfunction MarkerIcon({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"marker-icon\"\n aria-hidden=\"true\"\n className={cn(\"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4\", className)}\n {...props}\n />\n )\n}\n\nfunction MarkerContent({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"marker-content\"\n className={cn(\n \"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\nexport { Marker, MarkerIcon, MarkerContent, markerVariants }\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "marquee",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1"
7
+ },
8
+ "registryDependencies": [
9
+ "utils"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/marquee.tsx",
14
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { Marquee as MarqueePrimitive } from \"@ark-ui/react\"\n\nfunction Marquee({ className, ...props }: React.ComponentProps<typeof MarqueePrimitive.Root>) {\n return (\n <MarqueePrimitive.Root\n data-slot=\"marquee\"\n className={cn(\"group/marquee relative w-full overflow-hidden\", className)}\n {...props}\n />\n )\n}\n\nfunction MarqueeContext({ ...props }: React.ComponentProps<typeof MarqueePrimitive.Context>) {\n return <MarqueePrimitive.Context {...props} />\n}\n\nfunction MarqueeViewport({ className, ...props }: React.ComponentProps<typeof MarqueePrimitive.Viewport>) {\n return (\n <MarqueePrimitive.Viewport\n data-slot=\"marquee-viewport\"\n className={cn(\"flex w-full overflow-hidden\", className)}\n {...props}\n />\n )\n}\n\nfunction MarqueeContent({ className, ...props }: React.ComponentProps<typeof MarqueePrimitive.Content>) {\n return (\n <MarqueePrimitive.Content\n data-slot=\"marquee-content\"\n className={cn(\n \"flex shrink-0 items-center group-data-paused/marquee:paused data-reverse:shimmer-reverse data-horizontal:animate-marquee-x data-vertical:animate-marquee-y\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction MarqueeItem({ className, ...props }: React.ComponentProps<typeof MarqueePrimitive.Item>) {\n return <MarqueePrimitive.Item data-slot=\"marquee-item\" className={cn(\"shrink-0\", className)} {...props} />\n}\n\nfunction MarqueeEdge({ className, ...props }: React.ComponentProps<typeof MarqueePrimitive.Edge>) {\n return (\n <MarqueePrimitive.Edge\n data-slot=\"marquee-edge\"\n className={cn(\n \"pointer-events-none absolute inset-y-0 z-10 w-12 from-background to-transparent data-[side=end]:right-0 data-[side=end]:bg-linear-to-l data-[side=start]:left-0 data-[side=start]:bg-linear-to-r\",\n className\n )}\n {...props}\n />\n )\n}\n\nexport { Marquee, MarqueeContent, MarqueeContext, MarqueeEdge, MarqueeItem, MarqueeViewport }\n"
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "menubar",
3
+ "type": "ui",
4
+ "description": "Ark UI has no Menubar primitive. `Menubar` composes Ark `Menu` roots and adds the menubar behaviours Radix provides: one open menu at a time, hover switching between triggers once a menu is open, roving tabindex across triggers, and ArrowLe",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "lucide-react": "^1.41.0"
8
+ },
9
+ "registryDependencies": [
10
+ "controllable",
11
+ "utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/menubar.tsx",
16
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllable } from \"@/lib/controllable\"\nimport { Menu as MenubarPrimitive, Portal as PortalPrimitive } from \"@ark-ui/react\"\nimport { CheckIcon, ChevronRightIcon } from \"lucide-react\"\n\n/**\n * Ark UI has no Menubar primitive. `Menubar` composes Ark `Menu` roots and adds\n * the menubar behaviours Radix provides: one open menu at a time, hover\n * switching between triggers once a menu is open, roving tabindex across\n * triggers, and ArrowLeft/ArrowRight moving between menus.\n */\n\ntype MenubarContextValue = {\n value: string | null\n setValue: (value: string | null) => void\n loop: boolean\n register: (id: string) => () => void\n order: string[]\n rootRef: React.RefObject<HTMLDivElement | null>\n}\n\nconst MenubarContext = React.createContext<MenubarContextValue | null>(null)\nconst MenubarMenuContext = React.createContext<string | null>(null)\n\nfunction useMenubar(component: string) {\n const ctx = React.useContext(MenubarContext)\n if (!ctx) throw new Error(`${component} must be used within <Menubar>`)\n return ctx\n}\n\nfunction useMenubarMenu(component: string) {\n const id = React.useContext(MenubarMenuContext)\n if (!id) throw new Error(`${component} must be used within <MenubarMenu>`)\n return id\n}\n\nfunction Menubar({\n className,\n value: valueProp,\n defaultValue = null,\n onValueChange,\n loop = false,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"value\" | \"defaultValue\"> & {\n /** The id of the open menu (controlled). */\n value?: string | null\n defaultValue?: string | null\n onValueChange?: (value: string | null) => void\n /** Whether ArrowLeft/ArrowRight wrap around at the ends. */\n loop?: boolean\n}) {\n const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange)\n const [order, setOrder] = React.useState<string[]>([])\n const register = React.useCallback((id: string) => {\n setOrder((prev) => (prev.includes(id) ? prev : [...prev, id]))\n return () => setOrder((prev) => prev.filter((x) => x !== id))\n }, [])\n const rootRef = React.useRef<HTMLDivElement>(null)\n const ctx = React.useMemo(\n () => ({ value, setValue, loop, register, order, rootRef }),\n [value, setValue, loop, register, order]\n )\n return (\n <MenubarContext.Provider value={ctx}>\n <div\n ref={rootRef}\n role=\"menubar\"\n data-slot=\"menubar\"\n className={cn(\"flex h-8 items-center gap-0.5 rounded-lg border p-0.75\", className)}\n {...props}\n />\n </MenubarContext.Provider>\n )\n}\n\nfunction useMenubarNavigation() {\n const ctx = useMenubar(\"MenubarTrigger\")\n return React.useCallback(\n (fromId: string, direction: 1 | -1, open: boolean) => {\n const { order, loop, rootRef, setValue } = ctx\n const index = order.indexOf(fromId)\n let next = index + direction\n if (next < 0) next = loop ? order.length - 1 : 0\n if (next >= order.length) next = loop ? 0 : order.length - 1\n const nextId = order[next]\n if (!nextId || nextId === fromId) return\n const trigger = rootRef.current?.querySelector<HTMLElement>(\n `[data-slot=\"menubar-trigger\"][data-menubar-value=\"${nextId}\"]`\n )\n trigger?.focus()\n setValue(open ? nextId : null)\n },\n [ctx]\n )\n}\n\nfunction MenubarMenu({\n value: valueProp,\n positioning,\n lazyMount = true,\n unmountOnExit = true,\n onOpenChange,\n ...props\n}: Omit<React.ComponentProps<typeof MenubarPrimitive.Root>, \"open\" | \"defaultOpen\"> & {\n /** Identifies this menu within the menubar. Auto-generated when omitted. */\n value?: string\n}) {\n const ctx = useMenubar(\"MenubarMenu\")\n const generated = React.useId()\n const id = valueProp ?? generated\n const { register } = ctx\n React.useEffect(() => register(id), [register, id])\n return (\n <MenubarMenuContext.Provider value={id}>\n <MenubarPrimitive.Root\n open={ctx.value === id}\n onOpenChange={(details) => {\n ctx.setValue(details.open ? id : ctx.value === id ? null : ctx.value)\n onOpenChange?.(details)\n }}\n positioning={{\n placement: \"bottom-start\",\n gutter: 8,\n offset: { crossAxis: -4 },\n ...positioning,\n }}\n lazyMount={lazyMount}\n unmountOnExit={unmountOnExit}\n {...props}\n />\n </MenubarMenuContext.Provider>\n )\n}\n\nfunction MenubarTrigger({\n className,\n onPointerEnter,\n onKeyDown,\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {\n const ctx = useMenubar(\"MenubarTrigger\")\n const id = useMenubarMenu(\"MenubarTrigger\")\n const navigate = useMenubarNavigation()\n const isOpen = ctx.value === id\n const isFirst = ctx.order[0] === id\n return (\n <MenubarPrimitive.Trigger\n data-slot=\"menubar-trigger\"\n data-menubar-value={id}\n tabIndex={isOpen || (ctx.value === null && isFirst) ? 0 : -1}\n className={cn(\n \"flex items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted\",\n className\n )}\n onPointerEnter={(event) => {\n onPointerEnter?.(event)\n if (ctx.value !== null && ctx.value !== id) ctx.setValue(id)\n }}\n onKeyDown={(event) => {\n onKeyDown?.(event)\n if (event.defaultPrevented) return\n if (event.key === \"ArrowRight\" || event.key === \"ArrowLeft\") {\n event.preventDefault()\n navigate(id, event.key === \"ArrowRight\" ? 1 : -1, isOpen)\n }\n }}\n {...props}\n />\n )\n}\n\nfunction MenubarPortal({ ...props }: React.ComponentProps<typeof PortalPrimitive>) {\n return <PortalPrimitive {...props} />\n}\n\nfunction MenubarMenuContext_({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Context>) {\n return <MenubarPrimitive.Context {...props} />\n}\n\nfunction MenubarPositioner({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Positioner>) {\n return (\n <MenubarPrimitive.Positioner\n data-slot=\"menubar-positioner\"\n className={cn(\"[--z-index:50]\", className)}\n {...props}\n />\n )\n}\n\nfunction MenubarContent({ className, onKeyDown, ...props }: React.ComponentProps<typeof MenubarPrimitive.Content>) {\n const id = useMenubarMenu(\"MenubarContent\")\n const navigate = useMenubarNavigation()\n return (\n <MenubarPortal>\n <MenubarPositioner>\n <MenubarPrimitive.Content\n data-slot=\"menubar-content\"\n className={cn(\n \"z-50 min-w-36 origin-(--transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95\",\n className\n )}\n onKeyDown={(event) => {\n onKeyDown?.(event)\n if (event.defaultPrevented) return\n if (event.key !== \"ArrowRight\" && event.key !== \"ArrowLeft\") return\n const target = event.target as HTMLElement\n // Inside a submenu, Ark owns ArrowLeft (close) and ArrowRight.\n if (target.closest('[data-slot=\"menubar-sub-content\"]')) return\n // On a highlighted sub trigger, ArrowRight opens the submenu.\n if (\n event.key === \"ArrowRight\" &&\n event.currentTarget.querySelector('[data-slot=\"menubar-sub-trigger\"][data-highlighted]')\n )\n return\n event.preventDefault()\n navigate(id, event.key === \"ArrowRight\" ? 1 : -1, true)\n }}\n {...props}\n />\n </MenubarPositioner>\n </MenubarPortal>\n )\n}\n\nfunction MenubarArrow({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Arrow>) {\n return (\n <MenubarPrimitive.Arrow\n data-slot=\"menubar-arrow\"\n className={cn(\"[--arrow-background:var(--color-popover)] [--arrow-size:0.625rem]\", className)}\n {...props}\n >\n <MenubarPrimitive.ArrowTip data-slot=\"menubar-arrow-tip\" className=\"border-t border-l border-foreground/10\" />\n </MenubarPrimitive.Arrow>\n )\n}\n\nfunction MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.ItemGroup>) {\n return <MenubarPrimitive.ItemGroup data-slot=\"menubar-group\" {...props} />\n}\n\nfunction MenubarItem({\n className,\n inset,\n variant = \"default\",\n value,\n ...props\n}: Omit<React.ComponentProps<typeof MenubarPrimitive.Item>, \"value\"> & {\n value?: string\n inset?: boolean\n variant?: \"default\" | \"destructive\"\n}) {\n const id = React.useId()\n return (\n <MenubarPrimitive.Item\n data-slot=\"menubar-item\"\n data-inset={inset}\n data-variant={variant}\n value={value ?? id}\n className={cn(\n \"group/menubar-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-highlighted:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction MenubarItemText({ ...props }: React.ComponentProps<typeof MenubarPrimitive.ItemText>) {\n return <MenubarPrimitive.ItemText data-slot=\"menubar-item-text\" {...props} />\n}\n\nfunction MenubarItemIndicator({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.ItemIndicator>) {\n return (\n <MenubarPrimitive.ItemIndicator\n data-slot=\"menubar-item-indicator\"\n className={cn(\"pointer-events-none absolute right-2 flex items-center justify-center\", className)}\n {...props}\n />\n )\n}\n\nconst optionItemClassName =\n \"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\nfunction MenubarCheckboxItem({\n className,\n children,\n checked = false,\n inset,\n value,\n ...props\n}: Omit<React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>, \"checked\" | \"value\"> & {\n checked?: boolean\n value?: string\n inset?: boolean\n}) {\n const id = React.useId()\n return (\n <MenubarPrimitive.CheckboxItem\n data-slot=\"menubar-checkbox-item\"\n data-inset={inset}\n className={cn(optionItemClassName, className)}\n checked={checked}\n value={value ?? id}\n {...props}\n >\n <MenubarItemIndicator data-slot=\"menubar-checkbox-item-indicator\">\n <CheckIcon />\n </MenubarItemIndicator>\n {children}\n </MenubarPrimitive.CheckboxItem>\n )\n}\n\nfunction MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioItemGroup>) {\n return <MenubarPrimitive.RadioItemGroup data-slot=\"menubar-radio-group\" {...props} />\n}\n\nfunction MenubarRadioItem({\n className,\n children,\n inset,\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.RadioItem> & {\n inset?: boolean\n}) {\n return (\n <MenubarPrimitive.RadioItem\n data-slot=\"menubar-radio-item\"\n data-inset={inset}\n className={cn(optionItemClassName, className)}\n {...props}\n >\n <MenubarItemIndicator data-slot=\"menubar-radio-item-indicator\">\n <CheckIcon />\n </MenubarItemIndicator>\n {children}\n </MenubarPrimitive.RadioItem>\n )\n}\n\nfunction MenubarLabel({\n className,\n inset,\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.ItemGroupLabel> & {\n inset?: boolean\n}) {\n return (\n <MenubarPrimitive.ItemGroupLabel\n data-slot=\"menubar-label\"\n data-inset={inset}\n className={cn(\"px-1.5 py-1 text-sm font-medium data-inset:pl-7\", className)}\n {...props}\n />\n )\n}\n\nfunction MenubarSeparator({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Separator>) {\n return (\n <MenubarPrimitive.Separator\n data-slot=\"menubar-separator\"\n className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n {...props}\n />\n )\n}\n\nfunction MenubarShortcut({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"menubar-shortcut\"\n className={cn(\n \"ml-auto text-xs tracking-widest text-muted-foreground group-data-highlighted/menubar-item:text-accent-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction MenubarSub({\n lazyMount = true,\n unmountOnExit = true,\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Root>) {\n return <MenubarPrimitive.Root lazyMount={lazyMount} unmountOnExit={unmountOnExit} {...props} />\n}\n\nfunction MenubarSubTrigger({\n className,\n inset,\n children,\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.TriggerItem> & {\n inset?: boolean\n}) {\n return (\n <MenubarPrimitive.TriggerItem\n data-slot=\"menubar-sub-trigger\"\n data-inset={inset}\n className={cn(\n \"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n className\n )}\n {...props}\n >\n {children}\n <ChevronRightIcon className=\"ml-auto\" />\n </MenubarPrimitive.TriggerItem>\n )\n}\n\nfunction MenubarSubContent({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Content>) {\n return (\n <MenubarPortal>\n <MenubarPositioner>\n <MenubarPrimitive.Content\n data-slot=\"menubar-sub-content\"\n className={cn(\n \"z-50 min-w-32 origin-(--transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\",\n className\n )}\n {...props}\n />\n </MenubarPositioner>\n </MenubarPortal>\n )\n}\n\nexport {\n Menubar,\n MenubarArrow,\n MenubarCheckboxItem,\n MenubarContent,\n MenubarMenuContext_ as MenubarContext,\n MenubarGroup,\n MenubarItem,\n MenubarItemIndicator,\n MenubarItemText,\n MenubarLabel,\n MenubarMenu,\n MenubarPortal,\n MenubarPositioner,\n MenubarRadioGroup,\n MenubarRadioItem,\n MenubarSeparator,\n MenubarShortcut,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n}\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "message",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {},
6
+ "registryDependencies": [
7
+ "utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "components/ui/message.tsx",
12
+ "content": "import * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nfunction MessageGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div data-slot=\"message-group\" className={cn(\"flex min-w-0 flex-col gap-2\", className)} {...props} />\n}\n\nfunction Message({ className, align = \"start\", ...props }: React.ComponentProps<\"div\"> & { align?: \"start\" | \"end\" }) {\n return (\n <div\n data-slot=\"message\"\n data-align={align}\n className={cn(\n \"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction MessageAvatar({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"message-avatar\"\n className={cn(\n \"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction MessageContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"message-content\"\n className={cn(\n \"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction MessageHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"message-header\"\n className={cn(\n \"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction MessageFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"message-footer\"\n className={cn(\n \"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end\",\n className\n )}\n {...props}\n />\n )\n}\n\nexport { MessageGroup, Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader }\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "navigation-menu",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "class-variance-authority": "^0.7.1",
8
+ "lucide-react": "^1.41.0"
9
+ },
10
+ "registryDependencies": [
11
+ "utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/navigation-menu.tsx",
16
+ "content": "import * as React from \"react\"\nimport { cva } from \"class-variance-authority\"\nimport { cn } from \"@/lib/utils\"\nimport { NavigationMenu as NavigationMenuPrimitive } from \"@ark-ui/react\"\nimport { ChevronDownIcon } from \"lucide-react\"\n\nfunction NavigationMenu({\n className,\n children,\n viewport = true,\n ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {\n viewport?: boolean\n}) {\n return (\n <NavigationMenuPrimitive.Root\n data-slot=\"navigation-menu\"\n data-viewport={viewport}\n className={cn(\"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center\", className)}\n {...props}\n >\n {children}\n {viewport && (\n <NavigationMenuViewportPositioner>\n <NavigationMenuViewport />\n </NavigationMenuViewportPositioner>\n )}\n </NavigationMenuPrimitive.Root>\n )\n}\n\nfunction NavigationMenuContext({ ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Context>) {\n return <NavigationMenuPrimitive.Context {...props} />\n}\n\nfunction NavigationMenuList({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {\n return (\n <NavigationMenuPrimitive.List\n data-slot=\"navigation-menu-list\"\n className={cn(\"group flex flex-1 list-none items-center justify-center gap-0\", className)}\n {...props}\n />\n )\n}\n\nfunction NavigationMenuItem({\n className,\n value,\n ...props\n}: Omit<React.ComponentProps<typeof NavigationMenuPrimitive.Item>, \"value\"> & {\n /** Identifies the item. Auto-generated when omitted. */\n value?: string\n}) {\n const id = React.useId()\n return (\n <NavigationMenuPrimitive.Item\n data-slot=\"navigation-menu-item\"\n value={value ?? id}\n className={cn(\n // Ark measures the trigger via offsetLeft for the indicator, so the\n // item must not be the offset parent while a viewport is rendered.\n \"group-data-[viewport=false]/navigation-menu:relative\",\n className\n )}\n {...props}\n />\n )\n}\n\nconst navigationMenuTriggerStyle = cva(\n \"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus-visible:bg-muted\"\n)\n\nfunction NavigationMenuTrigger({\n className,\n children,\n ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {\n return (\n <NavigationMenuPrimitive.Trigger\n data-slot=\"navigation-menu-trigger\"\n className={cn(navigationMenuTriggerStyle(), \"group\", className)}\n {...props}\n >\n {children}{\" \"}\n <ChevronDownIcon\n className=\"relative top-px ml-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180\"\n aria-hidden=\"true\"\n />\n </NavigationMenuPrimitive.Trigger>\n )\n}\n\nfunction NavigationMenuContent({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {\n return (\n <NavigationMenuPrimitive.Content\n data-slot=\"navigation-menu-content\"\n className={cn(\n \"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-sm group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 **:data-[slot=navigation-menu-link]:focus-visible:ring-0 **:data-[slot=navigation-menu-link]:focus-visible:outline-none md:absolute md:w-auto data-open:animate-in data-open:fade-in group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction NavigationMenuViewportPositioner({\n className,\n ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.ViewportPositioner>) {\n return (\n <NavigationMenuPrimitive.ViewportPositioner\n data-slot=\"navigation-menu-viewport-positioner\"\n className={cn(\"absolute top-full left-0 isolate z-50 flex w-full justify-center\", className)}\n {...props}\n />\n )\n}\n\nfunction NavigationMenuViewport({\n className,\n ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {\n return (\n <NavigationMenuPrimitive.Viewport\n data-slot=\"navigation-menu-viewport\"\n className={cn(\n \"relative mt-1.5 h-(--viewport-height) w-full origin-[top_center] overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-sm ring-1 ring-foreground/10 transition-[width,height] duration-100 md:w-(--viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction NavigationMenuLink({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {\n return (\n <NavigationMenuPrimitive.Link\n data-slot=\"navigation-menu-link\"\n className={cn(\n \"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-current:bg-muted/50 data-current:hover:bg-muted data-current:focus-visible:bg-muted [&_svg:not([class*='size-'])]:size-4\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction NavigationMenuIndicator({\n className,\n ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {\n return (\n <NavigationMenuPrimitive.Indicator\n data-slot=\"navigation-menu-indicator\"\n className={cn(\n \"absolute top-full left-0 z-1 flex h-1.5 w-(--trigger-width) translate-x-(--trigger-x) items-end justify-center overflow-hidden transition-[width,transform] duration-200 data-open:animate-in data-open:fade-in data-closed:animate-out data-closed:fade-out\",\n className\n )}\n {...props}\n >\n <div className=\"relative top-[60%] size-2 rotate-45 rounded-tl-sm bg-border shadow-md\" />\n </NavigationMenuPrimitive.Indicator>\n )\n}\n\nfunction NavigationMenuArrow({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Arrow>) {\n return <NavigationMenuPrimitive.Arrow data-slot=\"navigation-menu-arrow\" className={cn(className)} {...props} />\n}\n\n/** Marks the active item inside `NavigationMenuIndicator` (the sliding pointer). */\nfunction NavigationMenuItemIndicator({\n className,\n children,\n ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.ItemIndicator>) {\n return (\n <NavigationMenuPrimitive.ItemIndicator\n data-slot=\"navigation-menu-item-indicator\"\n className={cn(\"inline-flex items-center justify-center\", className)}\n {...props}\n >\n {children ?? <span className=\"size-2 rotate-45 rounded-tl-sm bg-border\" />}\n </NavigationMenuPrimitive.ItemIndicator>\n )\n}\n\nexport {\n NavigationMenu,\n NavigationMenuArrow,\n NavigationMenuContent,\n NavigationMenuContext,\n NavigationMenuIndicator,\n NavigationMenuItem,\n NavigationMenuLink,\n NavigationMenuList,\n NavigationMenuTrigger,\n NavigationMenuViewport,\n NavigationMenuViewportPositioner,\n navigationMenuTriggerStyle,\n NavigationMenuItemIndicator,\n}\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "node-graph",
3
+ "type": "ui",
4
+ "description": "NodeGraph — a compositional, data-agnostic node editor in the spirit of visual scripting graphs. The consumer owns nodes and edges; the graph owns only the viewport (pan/zoom), the selection, and in-flight interactions (dragging nodes, draw",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "class-variance-authority": "^0.7.1",
8
+ "lucide-react": "^1.41.0"
9
+ },
10
+ "registryDependencies": [
11
+ "button",
12
+ "live-region",
13
+ "utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/node-graph.tsx",
18
+ "content": "import * as React from \"react\"\nimport { ark } from \"@ark-ui/react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { MaximizeIcon, MinusIcon, PlusIcon } from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\nimport { LiveRegion, useLiveRegion } from \"@/components/ui/live-region\"\n\n/**\n * NodeGraph — a compositional, data-agnostic node editor in the spirit of\n * visual scripting graphs. The consumer owns nodes and edges; the graph owns\n * only the viewport (pan/zoom), the selection, and in-flight interactions\n * (dragging nodes, drawing a connection, marquee selection). Everything else\n * is reported as an intent: `onNodesMove`, `onConnect`, `onConnectEnd`,\n * `onDisconnect` (alt+click on a pin), `onDelete`, `onSelectionChange`, and\n * `onPane/Node/Edge/PortContextMenu` (right-click without a drag).\n *\n * Anatomy:\n * NodeGraph\n * NodeGraphViewport pan/zoom pane, keyboard target\n * NodeGraphBackground dotted/lined grid that follows the viewport\n * NodeGraphSurface the transformed layer\n * NodeGraphEdges svg layer with NodeGraphEdge children\n * NodeGraphConnectionLine the edge being drawn\n * NodeGraphNode* absolutely positioned nodes\n * NodeGraphNodeHeader/Title/Subtitle/Body/Inputs/Outputs/Footer\n * NodeGraphPort > NodeGraphPortPin + NodeGraphPortLabel\n * NodeGraphSelectionBox marquee\n * NodeGraphControls zoom in/out/fit triggers + NodeGraphZoomValue\n * NodeGraphMinimap\n * NodeGraphEmpty\n */\n\ntype XY = { x: number; y: number }\ntype NodeGraphViewportState = { x: number; y: number; zoom: number }\ntype NodeGraphPortRef = { nodeId: string; portId: string }\ntype NodeGraphPortSide = \"input\" | \"output\"\ntype NodeGraphConnection = { source: NodeGraphPortRef; target: NodeGraphPortRef }\ntype NodeGraphSelection = { nodes: string[]; edges: string[] }\ntype NodeGraphNodeMove = { id: string; position: XY }\ntype NodeGraphConnectingState = {\n source: NodeGraphPortRef & { side: NodeGraphPortSide; type: string }\n /** Cursor position in graph coordinates. */\n position: XY\n target: (NodeGraphPortRef & { side: NodeGraphPortSide; type: string }) | null\n valid: boolean\n}\ntype NodeGraphRect = { x: number; y: number; width: number; height: number }\ntype NodeGraphPortDetails = NodeGraphPortRef & { side: NodeGraphPortSide; type: string }\n\nconst EMPTY_SELECTION: NodeGraphSelection = { nodes: [], edges: [] }\nconst INTERACTIVE = \"input, textarea, select, button, a, [contenteditable], [data-slot=node-graph-port-pin]\"\n\n/* ---------------------------------------------------------------------------\n * Layout store: node positions/sizes and port offsets, measured from the DOM.\n * Edges and the minimap subscribe to it instead of re-measuring themselves.\n * ------------------------------------------------------------------------- */\n\ntype PortLayout = { side: NodeGraphPortSide; type: string; el: HTMLElement | null; offset: XY }\ntype NodeLayout = { position: XY; width: number; height: number; ports: Map<string, PortLayout> }\n\nfunction createLayoutStore() {\n const nodes = new Map<string, NodeLayout>()\n const listeners = new Set<() => void>()\n let version = 0\n const emit = () => {\n version++\n listeners.forEach((l) => l())\n }\n return {\n nodes,\n subscribe: (l: () => void) => {\n listeners.add(l)\n return () => {\n listeners.delete(l)\n }\n },\n getVersion: () => version,\n emit,\n ensure(id: string) {\n let node = nodes.get(id)\n if (!node) {\n node = { position: { x: 0, y: 0 }, width: 0, height: 0, ports: new Map() }\n nodes.set(id, node)\n }\n return node\n },\n getPortPosition(ref: NodeGraphPortRef): XY | null {\n const node = nodes.get(ref.nodeId)\n const port = node?.ports.get(ref.portId)\n if (!node || !port) return null\n return { x: node.position.x + port.offset.x, y: node.position.y + port.offset.y }\n },\n getPort(ref: NodeGraphPortRef) {\n return nodes.get(ref.nodeId)?.ports.get(ref.portId) ?? null\n },\n getBounds(ids?: Iterable<string>): NodeGraphRect | null {\n let minX = Infinity\n let minY = Infinity\n let maxX = -Infinity\n let maxY = -Infinity\n for (const id of ids ?? nodes.keys()) {\n const node = nodes.get(id)\n if (!node) continue\n minX = Math.min(minX, node.position.x)\n minY = Math.min(minY, node.position.y)\n maxX = Math.max(maxX, node.position.x + node.width)\n maxY = Math.max(maxY, node.position.y + node.height)\n }\n if (!Number.isFinite(minX)) return null\n return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }\n },\n }\n}\n\ntype LayoutStore = ReturnType<typeof createLayoutStore>\n\n/* ---------------------------------------------------------------------------\n * Context\n * ------------------------------------------------------------------------- */\n\ntype NodeGraphContextValue = {\n store: LayoutStore\n viewport: NodeGraphViewportState\n setViewport: (next: NodeGraphViewportState | ((prev: NodeGraphViewportState) => NodeGraphViewportState)) => void\n viewportRef: React.RefObject<HTMLDivElement | null>\n size: { width: number; height: number }\n minZoom: number\n maxZoom: number\n snapGrid: number\n selection: NodeGraphSelection\n setSelection: (next: NodeGraphSelection) => void\n connecting: NodeGraphConnectingState | null\n marquee: NodeGraphRect | null\n draggingNodes: boolean\n panning: boolean\n spaceHeld: boolean\n /** Convert a client (screen) point to graph coordinates. */\n screenToGraph: (client: XY) => XY\n graphToScreen: (point: XY) => XY\n zoomIn: () => void\n zoomOut: () => void\n zoomTo: (zoom: number, client?: XY) => void\n fitView: (options?: { padding?: number; ids?: string[]; maxZoom?: number }) => void\n centerOn: (point: XY) => void\n announce: (message: string) => void\n // internal interaction hooks used by parts\n startNodeDrag: (nodeId: string, event: React.PointerEvent) => void\n startConnection: (source: NodeGraphConnectingState[\"source\"], event: React.PointerEvent) => void\n disconnect: (details: NodeGraphPortDetails) => void\n edgeAt: (details: NodeGraphPortDetails) => NodeGraphPortDetails | null\n deleteSelection: () => void\n nudgeSelection: (delta: XY, focusedNodeId?: string) => void\n selectAll: () => void\n onNodeClick: (nodeId: string, event: React.MouseEvent | React.KeyboardEvent) => void\n onEdgeClick: (edgeId: string, event: React.MouseEvent) => void\n}\n\nconst NodeGraphContext = React.createContext<NodeGraphContextValue | null>(null)\n\n/** Access the graph (viewport, selection, coordinate helpers) from any part or consumer code inside `NodeGraph`. */\nfunction useNodeGraph() {\n const ctx = React.useContext(NodeGraphContext)\n if (!ctx) throw new Error(\"NodeGraph parts must be used within <NodeGraph>\")\n return ctx\n}\n\n/** Re-render when node/port layout changes. */\nfunction useLayoutVersion(store: LayoutStore) {\n return React.useSyncExternalStore(store.subscribe, store.getVersion, store.getVersion)\n}\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))\nconst snap = (value: number, grid: number) => (grid > 0 ? Math.round(value / grid) * grid : value)\nconst sameSelection = (a: NodeGraphSelection, b: NodeGraphSelection) =>\n a.nodes.length === b.nodes.length &&\n a.edges.length === b.edges.length &&\n a.nodes.every((id, i) => b.nodes[i] === id) &&\n a.edges.every((id, i) => b.edges[i] === id)\n\nfunction useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (value: T) => void) {\n const [internal, setInternal] = React.useState(defaultValue)\n const controlled = value !== undefined\n const current = controlled ? value : internal\n // Tracks the latest value synchronously so back-to-back functional updates (wheel zoom) compose.\n const ref = React.useRef(current)\n React.useLayoutEffect(() => {\n ref.current = current\n }, [current])\n const onChangeRef = React.useRef(onChange)\n React.useEffect(() => {\n onChangeRef.current = onChange\n }, [onChange])\n const set = React.useCallback(\n (next: T | ((prev: T) => T)) => {\n const resolved = typeof next === \"function\" ? (next as (prev: T) => T)(ref.current) : next\n ref.current = resolved\n if (!controlled) setInternal(resolved)\n onChangeRef.current?.(resolved)\n },\n [controlled]\n )\n return [current, set] as const\n}\n\n/* ---------------------------------------------------------------------------\n * Root\n * ------------------------------------------------------------------------- */\n\ntype NodeGraphProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n viewport?: NodeGraphViewportState\n defaultViewport?: NodeGraphViewportState\n onViewportChange?: (viewport: NodeGraphViewportState) => void\n selection?: NodeGraphSelection\n defaultSelection?: NodeGraphSelection\n onSelectionChange?: (selection: NodeGraphSelection) => void\n minZoom?: number\n maxZoom?: number\n /** Grid size nodes snap to while dragging and nudging. `0` disables snapping. */\n snapGrid?: number\n /** Live position updates while nodes are dragged or nudged. */\n onNodesMove?: (moves: NodeGraphNodeMove[]) => void\n /** Fired once when a drag or nudge finishes, with the final positions. */\n onNodesMoveEnd?: (moves: NodeGraphNodeMove[]) => void\n /** A connection was completed on a valid port. `source` is always the output side. */\n onConnect?: (connection: NodeGraphConnection) => void\n /** A connection was released on empty space; `position` is in graph coordinates. */\n onConnectEnd?: (details: { source: NodeGraphConnectingState[\"source\"]; position: XY; client: XY }) => void\n /** Override the built-in check (sides differ, nodes differ, port types match or are \"any\"). */\n isValidConnection?: (connection: NodeGraphConnection & { sourceType: string; targetType: string }) => boolean\n /** Delete/Backspace with a selection. */\n onDelete?: (selection: NodeGraphSelection) => void\n /** Right-click on empty space (a right-drag pans instead). */\n onPaneContextMenu?: (details: { position: XY; client: XY }) => void\n /** Right-click on a node (selects it first unless already selected). */\n onNodeContextMenu?: (details: { id: string; position: XY; client: XY }) => void\n /** Right-click on an edge. */\n onEdgeContextMenu?: (details: { id: string; position: XY; client: XY }) => void\n /** Right-click on a port pin. */\n onPortContextMenu?: (details: NodeGraphPortDetails & { position: XY; client: XY }) => void\n /** Alt+click on a port pin: the conventional \"break links\" gesture. */\n onDisconnect?: (details: NodeGraphPortDetails) => void\n /**\n * Enables re-routing: return the other end of the single link attached to `port` (or null).\n * Dragging from such a pin then calls `onDisconnect(port)` and continues the drag from that other end.\n */\n edgeAt?: (port: NodeGraphPortDetails) => NodeGraphPortDetails | null\n}\n\nfunction NodeGraph({\n viewport: viewportProp,\n defaultViewport = { x: 0, y: 0, zoom: 1 },\n onViewportChange,\n selection: selectionProp,\n defaultSelection = EMPTY_SELECTION,\n onSelectionChange,\n minZoom = 0.25,\n maxZoom = 2,\n snapGrid = 20,\n onNodesMove,\n onNodesMoveEnd,\n onConnect,\n onConnectEnd,\n isValidConnection,\n onDelete,\n onPaneContextMenu,\n onNodeContextMenu,\n onEdgeContextMenu,\n onPortContextMenu,\n onDisconnect,\n edgeAt,\n className,\n children,\n ...props\n}: NodeGraphProps) {\n const store = React.useMemo(() => createLayoutStore(), [])\n const viewportRef = React.useRef<HTMLDivElement>(null)\n const [viewport, setViewportState] = useControllable(viewportProp, defaultViewport, onViewportChange)\n const [selection, setSelectionState] = useControllable(selectionProp, defaultSelection, onSelectionChange)\n const [size, setSize] = React.useState({ width: 0, height: 0 })\n const [connecting, setConnecting] = React.useState<NodeGraphConnectingState | null>(null)\n const [marquee, setMarquee] = React.useState<NodeGraphRect | null>(null)\n const [draggingNodes, setDraggingNodes] = React.useState(false)\n const [panning, setPanning] = React.useState(false)\n const [spaceHeld, setSpaceHeld] = React.useState(false)\n const { message: announcement, announce } = useLiveRegion()\n const spaceRef = React.useRef(false)\n React.useEffect(() => {\n const down = (event: KeyboardEvent) => {\n if (event.key !== \" \" || event.repeat) return\n const target = event.target as HTMLElement\n if (target.closest(\"input, textarea, select, button, [contenteditable]\")) return\n if (!viewportRef.current?.contains(target) && target !== document.body) return\n spaceRef.current = true\n setSpaceHeld(true)\n }\n const up = (event: KeyboardEvent) => {\n if (event.key !== \" \") return\n spaceRef.current = false\n setSpaceHeld(false)\n }\n const blur = () => {\n spaceRef.current = false\n setSpaceHeld(false)\n }\n window.addEventListener(\"keydown\", down)\n window.addEventListener(\"keyup\", up)\n window.addEventListener(\"blur\", blur)\n return () => {\n window.removeEventListener(\"keydown\", down)\n window.removeEventListener(\"keyup\", up)\n window.removeEventListener(\"blur\", blur)\n }\n }, [])\n\n const latest = React.useRef({\n viewport,\n selection,\n size,\n onNodesMove,\n onNodesMoveEnd,\n onConnect,\n onConnectEnd,\n onPaneContextMenu,\n onNodeContextMenu,\n onEdgeContextMenu,\n onPortContextMenu,\n onDisconnect,\n edgeAt,\n })\n React.useEffect(() => {\n latest.current = {\n viewport,\n selection,\n size,\n onNodesMove,\n onNodesMoveEnd,\n onConnect,\n onConnectEnd,\n onPaneContextMenu,\n onNodeContextMenu,\n onEdgeContextMenu,\n onPortContextMenu,\n onDisconnect,\n edgeAt,\n }\n })\n\n const setViewport = React.useCallback(\n (next: NodeGraphViewportState | ((prev: NodeGraphViewportState) => NodeGraphViewportState)) =>\n setViewportState((prev) => {\n const resolved = typeof next === \"function\" ? next(prev) : next\n return { ...resolved, zoom: clamp(resolved.zoom, minZoom, maxZoom) }\n }),\n [setViewportState, minZoom, maxZoom]\n )\n const setSelection = React.useCallback(\n (next: NodeGraphSelection) => {\n if (!sameSelection(latest.current.selection, next)) setSelectionState(next)\n },\n [setSelectionState]\n )\n\n const screenToGraph = React.useCallback((client: XY): XY => {\n const rect = viewportRef.current?.getBoundingClientRect()\n const vp = latest.current.viewport\n return {\n x: (client.x - (rect?.left ?? 0) - vp.x) / vp.zoom,\n y: (client.y - (rect?.top ?? 0) - vp.y) / vp.zoom,\n }\n }, [])\n const graphToScreen = React.useCallback((point: XY): XY => {\n const rect = viewportRef.current?.getBoundingClientRect()\n const vp = latest.current.viewport\n return { x: point.x * vp.zoom + vp.x + (rect?.left ?? 0), y: point.y * vp.zoom + vp.y + (rect?.top ?? 0) }\n }, [])\n\n /** Zoom keeping the graph point under `client` (defaults to the pane centre) fixed. */\n const zoomTo = React.useCallback(\n (zoom: number, client?: XY) => {\n const rect = viewportRef.current?.getBoundingClientRect()\n const cx = client ? client.x - (rect?.left ?? 0) : latest.current.size.width / 2\n const cy = client ? client.y - (rect?.top ?? 0) : latest.current.size.height / 2\n setViewport((prev) => {\n const next = clamp(zoom, minZoom, maxZoom)\n const gx = (cx - prev.x) / prev.zoom\n const gy = (cy - prev.y) / prev.zoom\n return { x: cx - gx * next, y: cy - gy * next, zoom: next }\n })\n },\n [setViewport, minZoom, maxZoom]\n )\n const zoomIn = React.useCallback(() => zoomTo(latest.current.viewport.zoom * 1.2), [zoomTo])\n const zoomOut = React.useCallback(() => zoomTo(latest.current.viewport.zoom / 1.2), [zoomTo])\n const fitView = React.useCallback(\n (options?: { padding?: number; ids?: string[]; maxZoom?: number }) => {\n const bounds = store.getBounds(options?.ids)\n const { width, height } = latest.current.size\n if (!bounds || !width || !height) return\n const padding = options?.padding ?? 40\n const zoom = clamp(\n Math.min(\n (width - padding * 2) / Math.max(bounds.width, 1),\n (height - padding * 2) / Math.max(bounds.height, 1)\n ),\n minZoom,\n options?.maxZoom ?? 1\n )\n setViewport({\n x: (width - bounds.width * zoom) / 2 - bounds.x * zoom,\n y: (height - bounds.height * zoom) / 2 - bounds.y * zoom,\n zoom,\n })\n },\n [store, setViewport, minZoom]\n )\n const centerOn = React.useCallback(\n (point: XY) =>\n setViewport((prev) => ({\n ...prev,\n x: latest.current.size.width / 2 - point.x * prev.zoom,\n y: latest.current.size.height / 2 - point.y * prev.zoom,\n })),\n [setViewport]\n )\n\n // Pane size for fitView / minimap.\n React.useLayoutEffect(() => {\n const el = viewportRef.current\n if (!el) return\n const update = () => setSize({ width: el.clientWidth, height: el.clientHeight })\n update()\n const observer = new ResizeObserver(update)\n observer.observe(el)\n return () => observer.disconnect()\n }, [])\n\n /* ----- pointer interactions: they attach move/up listeners to the window ----- */\n\n const track = React.useCallback((onMove: (event: PointerEvent) => void, onUp: (event: PointerEvent) => void) => {\n const up = (event: PointerEvent) => {\n window.removeEventListener(\"pointermove\", onMove)\n window.removeEventListener(\"pointerup\", up)\n window.removeEventListener(\"pointercancel\", up)\n onUp(event)\n }\n window.addEventListener(\"pointermove\", onMove)\n window.addEventListener(\"pointerup\", up)\n window.addEventListener(\"pointercancel\", up)\n }, [])\n\n const startNodeDrag = React.useCallback(\n (nodeId: string, event: React.PointerEvent) => {\n const startClient = { x: event.clientX, y: event.clientY }\n const current = latest.current.selection\n const additive = event.shiftKey || event.metaKey || event.ctrlKey\n const wasSelected = current.nodes.includes(nodeId)\n // Selection resolves on pointer down so a drag moves the whole group.\n const nodes = wasSelected ? current.nodes : additive ? [...current.nodes, nodeId] : [nodeId]\n if (!wasSelected) setSelection({ nodes, edges: additive ? current.edges : [] })\n const starts = new Map(nodes.map((id) => [id, { ...store.ensure(id).position }]))\n let moved = false\n let lastMoves: NodeGraphNodeMove[] = []\n track(\n (e) => {\n const zoom = latest.current.viewport.zoom\n const dx = (e.clientX - startClient.x) / zoom\n const dy = (e.clientY - startClient.y) / zoom\n if (!moved && Math.hypot(dx * zoom, dy * zoom) < 3) return\n if (!moved) {\n moved = true\n setDraggingNodes(true)\n }\n lastMoves = [...starts].map(([id, start]) => ({\n id,\n position: { x: snap(start.x + dx, snapGrid), y: snap(start.y + dy, snapGrid) },\n }))\n latest.current.onNodesMove?.(lastMoves)\n },\n () => {\n if (moved) {\n setDraggingNodes(false)\n latest.current.onNodesMoveEnd?.(lastMoves)\n } else if (wasSelected && additive) {\n setSelection({ nodes: current.nodes.filter((id) => id !== nodeId), edges: current.edges })\n } else if (wasSelected && current.nodes.length > 1) {\n setSelection({ nodes: [nodeId], edges: [] })\n }\n }\n )\n },\n [store, track, setSelection, snapGrid]\n )\n\n const validate = React.useCallback(\n (a: NodeGraphConnectingState[\"source\"], b: NodeGraphConnectingState[\"source\"]) => {\n if (a.side === b.side || a.nodeId === b.nodeId) return false\n const [source, target] = a.side === \"output\" ? [a, b] : [b, a]\n if (isValidConnection) {\n return isValidConnection({\n source: { nodeId: source.nodeId, portId: source.portId },\n target: { nodeId: target.nodeId, portId: target.portId },\n sourceType: source.type,\n targetType: target.type,\n })\n }\n return source.type === target.type || source.type === \"any\" || target.type === \"any\"\n },\n [isValidConnection]\n )\n\n const startConnection = React.useCallback(\n (source: NodeGraphConnectingState[\"source\"], event: React.PointerEvent) => {\n const position = screenToGraph({ x: event.clientX, y: event.clientY })\n let state: NodeGraphConnectingState = { source, position, target: null, valid: false }\n setConnecting(state)\n track(\n (e) => {\n const el = document\n .elementFromPoint(e.clientX, e.clientY)\n ?.closest<HTMLElement>(\"[data-slot=node-graph-port-pin]\")\n const target =\n el && el.dataset.nodeId && el.dataset.portId\n ? {\n nodeId: el.dataset.nodeId,\n portId: el.dataset.portId,\n side: (el.dataset.side as NodeGraphPortSide) ?? \"input\",\n type: el.dataset.type ?? \"any\",\n }\n : null\n const anchored = target && validate(source, target) ? store.getPortPosition(target) : null\n state = {\n source,\n position: anchored ?? screenToGraph({ x: e.clientX, y: e.clientY }),\n target,\n valid: !!anchored,\n }\n setConnecting(state)\n },\n (e) => {\n setConnecting(null)\n if (state.target && state.valid) {\n const [from, to] = source.side === \"output\" ? [source, state.target] : [state.target, source]\n latest.current.onConnect?.({\n source: { nodeId: from.nodeId, portId: from.portId },\n target: { nodeId: to.nodeId, portId: to.portId },\n })\n } else if (!state.target) {\n latest.current.onConnectEnd?.({\n source,\n position: screenToGraph({ x: e.clientX, y: e.clientY }),\n client: { x: e.clientX, y: e.clientY },\n })\n }\n }\n )\n },\n [screenToGraph, store, track, validate]\n )\n\n /** Pointer down on the pane itself: pan (middle/right button, space, or alt) or marquee (left). */\n const onPanePointerDown = React.useCallback(\n (event: React.PointerEvent) => {\n const target = event.target as HTMLElement\n if (target.closest(\"[data-slot=node-graph-controls], [data-slot=node-graph-minimap]\")) return\n const pin = target.closest<HTMLElement>(\"[data-slot=node-graph-port-pin]\")\n const nodeEl = target.closest<HTMLElement>(\"[data-slot=node-graph-node]\")\n const edgeEl = target.closest<HTMLElement>(\"[data-slot=node-graph-edge]\")\n const startClient = { x: event.clientX, y: event.clientY }\n const touch = event.pointerType === \"touch\"\n const isPan =\n event.button === 1 ||\n event.button === 2 ||\n spaceRef.current ||\n ((event.altKey || touch) && !nodeEl && !edgeEl && !pin)\n if (!isPan && (nodeEl || edgeEl)) return\n if (isPan) {\n event.preventDefault()\n const startViewport = latest.current.viewport\n let moved = false\n setPanning(true)\n // Right-click selects the node under the cursor like a left click would.\n const nodeId = nodeEl?.dataset.value\n if (event.button === 2 && nodeId && !latest.current.selection.nodes.includes(nodeId)) {\n setSelection({ nodes: [nodeId], edges: [] })\n }\n track(\n (e) => {\n const dx = e.clientX - startClient.x\n const dy = e.clientY - startClient.y\n if (!moved && Math.hypot(dx, dy) < 3) return\n moved = true\n setViewport({ ...startViewport, x: startViewport.x + dx, y: startViewport.y + dy })\n },\n (e) => {\n setPanning(false)\n if (!moved && event.button === 2) {\n const client = { x: e.clientX, y: e.clientY }\n const position = screenToGraph(client)\n const handlers = latest.current\n if (pin && pin.dataset.nodeId && pin.dataset.portId) {\n handlers.onPortContextMenu?.({\n nodeId: pin.dataset.nodeId,\n portId: pin.dataset.portId,\n side: (pin.dataset.side as NodeGraphPortSide) ?? \"input\",\n type: pin.dataset.type ?? \"any\",\n position,\n client,\n })\n } else if (nodeId) {\n handlers.onNodeContextMenu?.({ id: nodeId, position, client })\n } else if (edgeEl?.dataset.value) {\n handlers.onEdgeContextMenu?.({ id: edgeEl.dataset.value, position, client })\n } else {\n handlers.onPaneContextMenu?.({ position, client })\n }\n }\n }\n )\n return\n }\n if (event.button !== 0) return\n const additive = event.shiftKey || event.metaKey || event.ctrlKey\n const base = additive ? latest.current.selection : EMPTY_SELECTION\n if (!additive) setSelection(EMPTY_SELECTION)\n const origin = screenToGraph(startClient)\n let box: NodeGraphRect | null = null\n track(\n (e) => {\n const point = screenToGraph({ x: e.clientX, y: e.clientY })\n if (!box && Math.hypot(e.clientX - startClient.x, e.clientY - startClient.y) < 3) return\n box = {\n x: Math.min(origin.x, point.x),\n y: Math.min(origin.y, point.y),\n width: Math.abs(point.x - origin.x),\n height: Math.abs(point.y - origin.y),\n }\n setMarquee(box)\n const hits: string[] = []\n for (const [id, node] of store.nodes) {\n const intersects =\n node.position.x < box.x + box.width &&\n node.position.x + node.width > box.x &&\n node.position.y < box.y + box.height &&\n node.position.y + node.height > box.y\n if (intersects) hits.push(id)\n }\n setSelection({ nodes: [...new Set([...base.nodes, ...hits])], edges: base.edges })\n },\n () => setMarquee(null)\n )\n },\n [track, setViewport, setSelection, screenToGraph, store]\n )\n\n const disconnect = React.useCallback(\n (details: NodeGraphPortDetails) => {\n latest.current.onDisconnect?.(details)\n announce(`Broke links on ${details.side} ${details.portId}.`)\n },\n [announce]\n )\n\n const edgeAtStable = React.useCallback(\n (details: NodeGraphPortDetails) => latest.current.edgeAt?.(details) ?? null,\n []\n )\n\n const deleteSelection = React.useCallback(() => {\n const current = latest.current.selection\n if (!current.nodes.length && !current.edges.length) return\n onDelete?.(current)\n setSelection(EMPTY_SELECTION)\n announce(\n `Deleted ${current.nodes.length} node${current.nodes.length === 1 ? \"\" : \"s\"} and ${current.edges.length} connection${current.edges.length === 1 ? \"\" : \"s\"}.`\n )\n }, [onDelete, setSelection, announce])\n\n const nudgeSelection = React.useCallback(\n (delta: XY, focusedNodeId?: string) => {\n const current = latest.current.selection\n const ids = current.nodes.length ? current.nodes : focusedNodeId ? [focusedNodeId] : []\n if (!ids.length) return\n const moves = ids.map((id) => {\n const { position } = store.ensure(id)\n return { id, position: { x: position.x + delta.x, y: position.y + delta.y } }\n })\n latest.current.onNodesMove?.(moves)\n latest.current.onNodesMoveEnd?.(moves)\n announce(\n `Moved ${ids.length} node${ids.length === 1 ? \"\" : \"s\"} ${delta.x < 0 ? \"left\" : delta.x > 0 ? \"right\" : delta.y < 0 ? \"up\" : \"down\"}.`\n )\n },\n [store, announce]\n )\n\n const selectAll = React.useCallback(\n () => setSelection({ nodes: [...store.nodes.keys()], edges: latest.current.selection.edges }),\n [store, setSelection]\n )\n\n const onNodeClick = React.useCallback(\n (nodeId: string, event: React.MouseEvent | React.KeyboardEvent) => {\n const current = latest.current.selection\n const additive = event.shiftKey || event.metaKey || event.ctrlKey\n if (additive) {\n setSelection({\n nodes: current.nodes.includes(nodeId)\n ? current.nodes.filter((id) => id !== nodeId)\n : [...current.nodes, nodeId],\n edges: current.edges,\n })\n } else {\n setSelection({ nodes: [nodeId], edges: [] })\n }\n },\n [setSelection]\n )\n const onEdgeClick = React.useCallback(\n (edgeId: string, event: React.MouseEvent) => {\n const current = latest.current.selection\n const additive = event.shiftKey || event.metaKey || event.ctrlKey\n if (additive) {\n setSelection({\n nodes: current.nodes,\n edges: current.edges.includes(edgeId)\n ? current.edges.filter((id) => id !== edgeId)\n : [...current.edges, edgeId],\n })\n } else {\n setSelection({ nodes: [], edges: [edgeId] })\n }\n },\n [setSelection]\n )\n\n const ctx = React.useMemo<NodeGraphContextValue>(\n () => ({\n store,\n viewport,\n setViewport,\n viewportRef,\n size,\n minZoom,\n maxZoom,\n snapGrid,\n selection,\n setSelection,\n connecting,\n marquee,\n draggingNodes,\n panning,\n spaceHeld,\n screenToGraph,\n graphToScreen,\n zoomIn,\n zoomOut,\n zoomTo,\n fitView,\n centerOn,\n announce,\n startNodeDrag,\n startConnection,\n disconnect,\n edgeAt: edgeAtStable,\n deleteSelection,\n nudgeSelection,\n selectAll,\n onNodeClick,\n onEdgeClick,\n }),\n [\n store,\n viewport,\n setViewport,\n size,\n minZoom,\n maxZoom,\n snapGrid,\n selection,\n setSelection,\n connecting,\n marquee,\n draggingNodes,\n panning,\n spaceHeld,\n screenToGraph,\n graphToScreen,\n zoomIn,\n zoomOut,\n zoomTo,\n fitView,\n centerOn,\n announce,\n startNodeDrag,\n startConnection,\n disconnect,\n edgeAtStable,\n deleteSelection,\n nudgeSelection,\n selectAll,\n onNodeClick,\n onEdgeClick,\n ]\n )\n\n return (\n <NodeGraphContext.Provider value={ctx}>\n <PanePointerDownContext.Provider value={onPanePointerDown}>\n <div\n data-slot=\"node-graph\"\n data-connecting={connecting ? \"\" : undefined}\n data-dragging={draggingNodes ? \"\" : undefined}\n data-panning={panning ? \"\" : undefined}\n className={cn(\"relative flex min-h-0 flex-1 flex-col\", className)}\n {...props}\n >\n {children}\n <LiveRegion data-slot=\"node-graph-live-region\" message={announcement} />\n </div>\n </PanePointerDownContext.Provider>\n </NodeGraphContext.Provider>\n )\n}\n\nconst PanePointerDownContext = React.createContext<((event: React.PointerEvent) => void) | null>(null)\n\n/* ---------------------------------------------------------------------------\n * Viewport\n * ------------------------------------------------------------------------- */\n\nfunction NodeGraphViewport({\n className,\n children,\n panOnScroll = false,\n onKeyDown,\n ...props\n}: React.ComponentProps<\"div\"> & {\n /** Plain wheel pans instead of zooming (pinch / ctrl+wheel always zooms). */\n panOnScroll?: boolean\n}) {\n const ctx = useNodeGraph()\n const onPanePointerDown = React.useContext(PanePointerDownContext)\n const { viewportRef, setViewport, zoomTo, connecting, panning, spaceHeld } = ctx\n const ctxRef = React.useRef(ctx)\n React.useEffect(() => {\n ctxRef.current = ctx\n })\n\n // Wheel must be non-passive to prevent page scroll.\n React.useEffect(() => {\n const el = viewportRef.current\n if (!el) return\n const onWheel = (event: WheelEvent) => {\n event.preventDefault()\n const zooming = event.ctrlKey || event.metaKey || !panOnScroll\n if (zooming) {\n const factor = Math.exp(-event.deltaY * (event.ctrlKey ? 0.01 : 0.002))\n zoomTo(ctxRef.current.viewport.zoom * factor, { x: event.clientX, y: event.clientY })\n } else {\n setViewport((prev) => ({ ...prev, x: prev.x - event.deltaX, y: prev.y - event.deltaY }))\n }\n }\n el.addEventListener(\"wheel\", onWheel, { passive: false })\n return () => el.removeEventListener(\"wheel\", onWheel)\n }, [viewportRef, setViewport, zoomTo, panOnScroll])\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(event)\n if (event.defaultPrevented) return\n const target = event.target as HTMLElement\n if (target.closest(\"input, textarea, select, [contenteditable]\")) return\n const focusedNode = target.closest<HTMLElement>(\"[data-slot=node-graph-node]\")?.dataset.value\n const step = (event.shiftKey ? 10 : 1) * (ctx.snapGrid || 10)\n const meta = event.metaKey || event.ctrlKey\n switch (event.key) {\n case \" \":\n if (!focusedNode) event.preventDefault()\n break\n case \"Delete\":\n case \"Backspace\":\n event.preventDefault()\n ctx.deleteSelection()\n break\n case \"Escape\":\n ctx.setSelection(EMPTY_SELECTION)\n break\n case \"a\":\n case \"A\":\n if (meta) {\n event.preventDefault()\n ctx.selectAll()\n }\n break\n case \"ArrowLeft\":\n event.preventDefault()\n ctx.nudgeSelection({ x: -step, y: 0 }, focusedNode)\n break\n case \"ArrowRight\":\n event.preventDefault()\n ctx.nudgeSelection({ x: step, y: 0 }, focusedNode)\n break\n case \"ArrowUp\":\n event.preventDefault()\n ctx.nudgeSelection({ x: 0, y: -step }, focusedNode)\n break\n case \"ArrowDown\":\n event.preventDefault()\n ctx.nudgeSelection({ x: 0, y: step }, focusedNode)\n break\n case \"=\":\n case \"+\":\n if (meta) {\n event.preventDefault()\n ctx.zoomIn()\n }\n break\n case \"-\":\n if (meta) {\n event.preventDefault()\n ctx.zoomOut()\n }\n break\n case \"0\":\n if (meta) {\n event.preventDefault()\n ctx.fitView()\n }\n break\n }\n }\n\n return (\n <div\n ref={viewportRef}\n data-slot=\"node-graph-viewport\"\n tabIndex={0}\n role=\"application\"\n aria-label=\"Node graph\"\n data-connecting={connecting ? \"\" : undefined}\n data-panning={panning ? \"\" : undefined}\n data-space={spaceHeld ? \"\" : undefined}\n className={cn(\n \"relative min-h-0 flex-1 touch-none overflow-hidden rounded-xl border bg-muted/30 outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n \"data-connecting:cursor-crosshair data-panning:cursor-grabbing data-space:cursor-grab\",\n className\n )}\n onPointerDown={(event) => onPanePointerDown?.(event)}\n onContextMenu={(event) => event.preventDefault()}\n onKeyDown={handleKeyDown}\n {...props}\n >\n {children}\n </div>\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Background grid\n * ------------------------------------------------------------------------- */\n\nfunction NodeGraphBackground({\n className,\n variant = \"dots\",\n gap = 20,\n ...props\n}: React.ComponentProps<\"svg\"> & { variant?: \"dots\" | \"lines\"; gap?: number }) {\n const { viewport } = useNodeGraph()\n const id = React.useId()\n const size = gap * viewport.zoom\n const major = size * 5\n return (\n <svg\n data-slot=\"node-graph-background\"\n aria-hidden\n className={cn(\"pointer-events-none absolute inset-0 size-full text-foreground/60\", className)}\n {...props}\n >\n <pattern\n id={`${id}-minor`}\n x={viewport.x % size}\n y={viewport.y % size}\n width={size}\n height={size}\n patternUnits=\"userSpaceOnUse\"\n >\n {variant === \"dots\" ? (\n <circle\n cx={size / 2}\n cy={size / 2}\n r={Math.max(0.6, viewport.zoom * 0.9)}\n fill=\"currentColor\"\n opacity={0.28}\n />\n ) : (\n <path d={`M ${size} 0 L 0 0 0 ${size}`} fill=\"none\" stroke=\"currentColor\" strokeWidth={1} opacity={0.08} />\n )}\n </pattern>\n <pattern\n id={`${id}-major`}\n x={viewport.x % major}\n y={viewport.y % major}\n width={major}\n height={major}\n patternUnits=\"userSpaceOnUse\"\n >\n <path d={`M ${major} 0 L 0 0 0 ${major}`} fill=\"none\" stroke=\"currentColor\" strokeWidth={1} opacity={0.1} />\n </pattern>\n <rect width=\"100%\" height=\"100%\" fill={`url(#${id}-minor)`} />\n <rect width=\"100%\" height=\"100%\" fill={`url(#${id}-major)`} />\n </svg>\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Surface (transformed layer), edges, connection line, marquee\n * ------------------------------------------------------------------------- */\n\nfunction NodeGraphSurface({ className, style, ...props }: React.ComponentProps<\"div\">) {\n const { viewport } = useNodeGraph()\n return (\n <div\n data-slot=\"node-graph-surface\"\n className={cn(\"absolute top-0 left-0 origin-top-left\", className)}\n style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, ...style }}\n {...props}\n />\n )\n}\n\nfunction NodeGraphEdges({ className, ...props }: React.ComponentProps<\"svg\">) {\n return (\n <svg\n data-slot=\"node-graph-edges\"\n className={cn(\"pointer-events-none absolute top-0 left-0 size-px overflow-visible\", className)}\n {...props}\n />\n )\n}\n\n/** Cubic path between two points; horizontal handles scale with distance like a scripting graph. */\nfunction getBezierPath(source: XY, target: XY) {\n const dx = target.x - source.x\n const offset = clamp(Math.abs(dx) * 0.5, 40, 240) + (dx < 0 ? Math.min(Math.abs(dx) * 0.25, 120) : 0)\n return `M ${source.x} ${source.y} C ${source.x + offset} ${source.y}, ${target.x - offset} ${target.y}, ${target.x} ${target.y}`\n}\n\nfunction NodeGraphEdge({\n value,\n source,\n target,\n selected: selectedProp,\n className,\n onClick,\n children,\n ...props\n}: Omit<React.ComponentProps<\"g\">, \"children\" | \"target\"> & {\n value: string\n source: NodeGraphPortRef\n target: NodeGraphPortRef\n selected?: boolean\n /** Render prop for labels; receives the path midpoint. */\n children?: (details: { midpoint: XY; from: XY; to: XY }) => React.ReactNode\n}) {\n const { store, selection, onEdgeClick } = useNodeGraph()\n useLayoutVersion(store)\n const from = store.getPortPosition(source)\n const to = store.getPortPosition(target)\n if (!from || !to) return null\n const selected = selectedProp ?? selection.edges.includes(value)\n const d = getBezierPath(from, to)\n const midpoint = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }\n const type = store.getPort(source)?.type\n return (\n <g\n data-slot=\"node-graph-edge\"\n data-value={value}\n data-selected={selected ? \"\" : undefined}\n data-type={type}\n className={cn(\"group/edge pointer-events-auto cursor-pointer text-muted-foreground\", className)}\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) onEdgeClick(value, event)\n }}\n {...props}\n >\n <path d={d} fill=\"none\" stroke=\"transparent\" strokeWidth={14} />\n <path\n d={d}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n className=\"transition-[stroke-width] group-hover/edge:stroke-3 group-data-selected/edge:stroke-3 group-data-selected/edge:drop-shadow-[0_0_4px_currentColor]\"\n />\n {children?.({ midpoint, from, to })}\n </g>\n )\n}\n\n/** The edge being drawn from a pin. Renders nothing when idle. */\nfunction NodeGraphConnectionLine({ className, ...props }: React.ComponentProps<\"svg\">) {\n const { store, connecting } = useNodeGraph()\n useLayoutVersion(store)\n if (!connecting) return null\n const from = store.getPortPosition(connecting.source)\n if (!from) return null\n const [a, b] = connecting.source.side === \"output\" ? [from, connecting.position] : [connecting.position, from]\n return (\n <svg\n data-slot=\"node-graph-connection-line\"\n data-type={connecting.source.type}\n data-valid={connecting.valid ? \"\" : undefined}\n data-invalid={connecting.target && !connecting.valid ? \"\" : undefined}\n className={cn(\n \"pointer-events-none absolute top-0 left-0 size-px overflow-visible text-foreground data-invalid:text-destructive\",\n className\n )}\n {...props}\n >\n <path\n d={getBezierPath(a, b)}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeDasharray={connecting.valid ? undefined : \"6 4\"}\n />\n <circle cx={b.x} cy={b.y} r={3} fill=\"currentColor\" />\n </svg>\n )\n}\n\n/** Marquee selection rectangle. Renders nothing when idle. */\nfunction NodeGraphSelectionBox({ className, style, ...props }: React.ComponentProps<\"div\">) {\n const { marquee, viewport } = useNodeGraph()\n if (!marquee) return null\n return (\n <div\n data-slot=\"node-graph-selection-box\"\n className={cn(\"pointer-events-none absolute rounded-sm border border-primary/60 bg-primary/10\", className)}\n style={{\n left: marquee.x * viewport.zoom + viewport.x,\n top: marquee.y * viewport.zoom + viewport.y,\n width: marquee.width * viewport.zoom,\n height: marquee.height * viewport.zoom,\n ...style,\n }}\n {...props}\n />\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Nodes\n * ------------------------------------------------------------------------- */\n\ntype NodeContextValue = {\n value: string\n registerPort: (portId: string, layout: PortLayout) => () => void\n measure: () => void\n}\nconst NodeContext = React.createContext<NodeContextValue | null>(null)\n\nfunction useNodeGraphNode() {\n const ctx = React.useContext(NodeContext)\n if (!ctx) throw new Error(\"Node parts must be used within <NodeGraphNode>\")\n return ctx\n}\n\nfunction NodeGraphNode({\n value,\n position,\n selected: selectedProp,\n className,\n style,\n onPointerDown,\n onKeyDown,\n onDoubleClick,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string; position: XY; selected?: boolean }) {\n const graph = useNodeGraph()\n const { store, selection, draggingNodes, startNodeDrag, onNodeClick } = graph\n const ref = React.useRef<HTMLDivElement>(null)\n const selected = selectedProp ?? selection.nodes.includes(value)\n const zoomRef = React.useRef(graph.viewport.zoom)\n React.useLayoutEffect(() => {\n zoomRef.current = graph.viewport.zoom\n }, [graph.viewport.zoom])\n\n const measure = React.useCallback(() => {\n const el = ref.current\n if (!el) return\n const zoom = zoomRef.current || 1\n const rect = el.getBoundingClientRect()\n const node = store.ensure(value)\n node.width = rect.width / zoom\n node.height = rect.height / zoom\n for (const port of node.ports.values()) {\n const pin = port.el?.getBoundingClientRect()\n if (!pin) continue\n port.offset = {\n x: (pin.left + pin.width / 2 - rect.left) / zoom,\n y: (pin.top + pin.height / 2 - rect.top) / zoom,\n }\n }\n store.emit()\n }, [store, value])\n\n // Position from props is the source of truth; keep the store in sync for edges/minimap.\n React.useLayoutEffect(() => {\n const node = store.ensure(value)\n node.position = position\n store.emit()\n }, [store, value, position])\n React.useLayoutEffect(() => {\n measure()\n const el = ref.current\n if (!el) return\n const observer = new ResizeObserver(() => measure())\n observer.observe(el)\n return () => observer.disconnect()\n }, [measure])\n React.useEffect(\n () => () => {\n store.nodes.delete(value)\n store.emit()\n },\n [store, value]\n )\n\n const nodeCtx = React.useMemo<NodeContextValue>(\n () => ({\n value,\n registerPort: (portId, layout) => {\n const node = store.ensure(value)\n node.ports.set(portId, layout)\n measure()\n return () => {\n node.ports.delete(portId)\n store.emit()\n }\n },\n measure,\n }),\n [store, value, measure]\n )\n\n return (\n <NodeContext.Provider value={nodeCtx}>\n <div\n ref={ref}\n data-slot=\"node-graph-node\"\n data-value={value}\n data-selected={selected ? \"\" : undefined}\n data-dragging={draggingNodes && selected ? \"\" : undefined}\n tabIndex={0}\n className={cn(\n \"group/node absolute top-0 left-0 flex w-56 flex-col rounded-lg border bg-card text-card-foreground shadow-md outline-none\",\n \"cursor-grab data-dragging:cursor-grabbing data-dragging:shadow-xl\",\n \"focus-visible:ring-2 focus-visible:ring-ring/50 data-selected:border-primary data-selected:ring-2 data-selected:ring-primary/40\",\n className\n )}\n style={{ transform: `translate(${position.x}px, ${position.y}px)`, ...style }}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n if (event.defaultPrevented || event.button !== 0) return\n if ((event.target as HTMLElement).closest(INTERACTIVE)) return\n if (graph.spaceHeld) return\n event.stopPropagation()\n startNodeDrag(value, event)\n }}\n onDoubleClick={(event) => {\n onDoubleClick?.(event)\n if (!event.defaultPrevented) graph.fitView({ ids: [value], padding: 80, maxZoom: 1.5 })\n }}\n onKeyDown={(event) => {\n onKeyDown?.(event)\n if (event.defaultPrevented || event.target !== event.currentTarget) return\n if (event.key === \" \" || event.key === \"Enter\") {\n event.preventDefault()\n onNodeClick(value, event)\n }\n }}\n {...props}\n >\n {children}\n </div>\n </NodeContext.Provider>\n )\n}\n\n/** Header slot for buttons (menus, run triggers). Revealed on hover/selection; clicks never start a drag. */\nfunction NodeGraphNodeActions({ className, onPointerDown, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"node-graph-node-actions\"\n className={cn(\n \"ml-auto flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-focus-within/node:opacity-100 group-hover/node:opacity-100 group-data-selected/node:opacity-100\",\n className\n )}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n event.stopPropagation()\n }}\n {...props}\n />\n )\n}\n\nfunction NodeGraphNodeHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"node-graph-node-header\"\n className={cn(\n \"flex items-center gap-2 rounded-t-[inherit] border-b bg-muted/60 px-3 py-2 text-sm font-medium [&_svg:not([class*='size-'])]:size-4\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction NodeGraphNodeTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div data-slot=\"node-graph-node-title\" className={cn(\"truncate leading-tight\", className)} {...props} />\n}\n\nfunction NodeGraphNodeSubtitle({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"node-graph-node-subtitle\"\n className={cn(\"truncate text-xs font-normal text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\nfunction NodeGraphNodeBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div data-slot=\"node-graph-node-body\" className={cn(\"grid grid-cols-2 gap-x-2 py-2\", className)} {...props} />\n}\n\nfunction NodeGraphNodeInputs({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div data-slot=\"node-graph-node-inputs\" className={cn(\"flex flex-col gap-1\", className)} {...props} />\n}\n\nfunction NodeGraphNodeOutputs({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"node-graph-node-outputs\"\n className={cn(\"col-start-2 flex flex-col items-end gap-1\", className)}\n {...props}\n />\n )\n}\n\nfunction NodeGraphNodeFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"node-graph-node-footer\"\n className={cn(\"rounded-b-[inherit] border-t px-3 py-1.5 text-xs text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Ports\n * ------------------------------------------------------------------------- */\n\ntype PortContextValue = { value: string; side: NodeGraphPortSide; type: string; connected: boolean }\nconst PortContext = React.createContext<PortContextValue | null>(null)\n\nfunction NodeGraphPort({\n value,\n side,\n type = \"any\",\n connected = false,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string; side: NodeGraphPortSide; type?: string; connected?: boolean }) {\n const ctx = React.useMemo(() => ({ value, side, type, connected }), [value, side, type, connected])\n return (\n <PortContext.Provider value={ctx}>\n <div\n data-slot=\"node-graph-port\"\n data-side={side}\n data-type={type}\n data-connected={connected ? \"\" : undefined}\n className={cn(\n \"flex min-h-6 items-center gap-1.5 text-xs\",\n side === \"input\" ? \"pr-2 pl-0\" : \"flex-row-reverse pr-0 pl-2\",\n className\n )}\n {...props}\n />\n </PortContext.Provider>\n )\n}\n\nconst pinVariants = cva(\n \"relative z-10 box-content shrink-0 cursor-crosshair border-2 border-current bg-background transition-transform hover:scale-125 data-connected:bg-current data-target:scale-125 data-target:ring-2 data-target:ring-current/40\",\n {\n variants: {\n variant: {\n circle: \"size-2.5 rounded-full\",\n exec: \"size-3 border-0 bg-current opacity-45 [clip-path:polygon(0_0,60%_0,100%_50%,60%_100%,0_100%)] data-connected:opacity-100\",\n },\n },\n defaultVariants: { variant: \"circle\" },\n }\n)\n\n/** The connection handle. Sits on the node's edge: inputs are pulled left, outputs right. */\nfunction NodeGraphPortPin({\n className,\n variant,\n onPointerDown,\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof pinVariants>) {\n const graph = useNodeGraph()\n const node = useNodeGraphNode()\n const port = React.useContext(PortContext)\n if (!port) throw new Error(\"NodeGraphPortPin must be used within <NodeGraphPort>\")\n const ref = React.useRef<HTMLDivElement>(null)\n const { registerPort } = node\n React.useLayoutEffect(\n () => registerPort(port.value, { side: port.side, type: port.type, el: ref.current, offset: { x: 0, y: 0 } }),\n [registerPort, port.value, port.side, port.type]\n )\n const connecting = graph.connecting\n const isTarget = connecting?.target?.nodeId === node.value && connecting.target.portId === port.value\n const isSource = connecting?.source.nodeId === node.value && connecting.source.portId === port.value\n return (\n <div\n ref={ref}\n data-slot=\"node-graph-port-pin\"\n data-node-id={node.value}\n data-port-id={port.value}\n data-side={port.side}\n data-type={port.type}\n data-connected={port.connected || isSource ? \"\" : undefined}\n data-target={isTarget ? (connecting?.valid ? \"valid\" : \"invalid\") : undefined}\n aria-label={`${port.side} ${port.value}`}\n className={cn(\n pinVariants({ variant }),\n port.side === \"input\" ? \"-ml-1.75\" : \"-mr-1.75\",\n isTarget && !connecting?.valid && \"cursor-not-allowed text-destructive\",\n className\n )}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n if (event.defaultPrevented || event.button !== 0) return\n event.stopPropagation()\n event.preventDefault()\n const details = { nodeId: node.value, portId: port.value, side: port.side, type: port.type }\n if (event.altKey) {\n graph.disconnect(details)\n return\n }\n const other = port.connected ? graph.edgeAt(details) : null\n if (other) {\n // Pick the existing link up: drop it, then keep dragging from its far end.\n graph.disconnect(details)\n graph.startConnection(other, event)\n return\n }\n graph.startConnection(details, event)\n }}\n {...props}\n />\n )\n}\n\nfunction NodeGraphPortLabel({ className, ...props }: React.ComponentProps<\"span\">) {\n return <span data-slot=\"node-graph-port-label\" className={cn(\"truncate\", className)} {...props} />\n}\n\n/* ---------------------------------------------------------------------------\n * Controls, minimap, empty\n * ------------------------------------------------------------------------- */\n\nfunction NodeGraphControls({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"node-graph-controls\"\n className={cn(\n \"absolute bottom-3 left-3 z-20 flex items-center gap-1 rounded-lg border bg-background/90 p-1 shadow-sm backdrop-blur-sm\",\n className\n )}\n onPointerDown={(event) => event.stopPropagation()}\n {...props}\n />\n )\n}\n\ntype ControlTriggerProps = React.ComponentProps<typeof Button>\n\nfunction NodeGraphZoomInTrigger({ asChild, children, onClick, ...props }: ControlTriggerProps) {\n const { zoomIn } = useNodeGraph()\n return (\n <Button\n data-slot=\"node-graph-zoom-in-trigger\"\n variant=\"ghost\"\n size=\"icon-sm\"\n asChild={asChild}\n aria-label=\"Zoom in\"\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) zoomIn()\n }}\n {...props}\n >\n {asChild ? children : (children ?? <PlusIcon />)}\n </Button>\n )\n}\n\nfunction NodeGraphZoomOutTrigger({ asChild, children, onClick, ...props }: ControlTriggerProps) {\n const { zoomOut } = useNodeGraph()\n return (\n <Button\n data-slot=\"node-graph-zoom-out-trigger\"\n variant=\"ghost\"\n size=\"icon-sm\"\n asChild={asChild}\n aria-label=\"Zoom out\"\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) zoomOut()\n }}\n {...props}\n >\n {asChild ? children : (children ?? <MinusIcon />)}\n </Button>\n )\n}\n\nfunction NodeGraphFitViewTrigger({ asChild, children, onClick, ...props }: ControlTriggerProps) {\n const { fitView } = useNodeGraph()\n return (\n <Button\n data-slot=\"node-graph-fit-view-trigger\"\n variant=\"ghost\"\n size=\"icon-sm\"\n asChild={asChild}\n aria-label=\"Fit view\"\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) fitView()\n }}\n {...props}\n >\n {asChild ? children : (children ?? <MaximizeIcon />)}\n </Button>\n )\n}\n\n/** Current zoom as a percentage; click resets to 100%. */\nfunction NodeGraphZoomValue({\n className,\n asChild,\n children,\n onClick,\n ...props\n}: React.ComponentProps<typeof ark.button>) {\n const { viewport, zoomTo } = useNodeGraph()\n return (\n <ark.button\n data-slot=\"node-graph-zoom-value\"\n type=\"button\"\n asChild={asChild}\n aria-label=\"Reset zoom\"\n className={cn(\n \"h-7 min-w-12 rounded-md px-2 text-xs font-medium text-muted-foreground tabular-nums hover:bg-muted hover:text-foreground\",\n className\n )}\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) zoomTo(1)\n }}\n {...props}\n >\n {asChild ? children : (children ?? `${Math.round(viewport.zoom * 100)}%`)}\n </ark.button>\n )\n}\n\nfunction NodeGraphMinimap({\n className,\n width = 180,\n height = 120,\n ...props\n}: React.ComponentProps<\"svg\"> & { width?: number; height?: number }) {\n const { store, viewport, size, selection, centerOn } = useNodeGraph()\n useLayoutVersion(store)\n const nodes = [...store.nodes]\n const view = {\n x: -viewport.x / viewport.zoom,\n y: -viewport.y / viewport.zoom,\n width: size.width / viewport.zoom,\n height: size.height / viewport.zoom,\n }\n const bounds = store.getBounds() ?? view\n const minX = Math.min(bounds.x, view.x)\n const minY = Math.min(bounds.y, view.y)\n const maxX = Math.max(bounds.x + bounds.width, view.x + view.width)\n const maxY = Math.max(bounds.y + bounds.height, view.y + view.height)\n const pad = Math.max(maxX - minX, maxY - minY) * 0.05\n const viewBox = `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`\n const scale = (maxX - minX + pad * 2) / width\n\n // Match \"meet\" scaling: the viewBox is centred inside the svg box.\n const pointToGraph = (svg: SVGSVGElement, client: XY) => {\n const rect = svg.getBoundingClientRect()\n const vbW = maxX - minX + pad * 2\n const vbH = maxY - minY + pad * 2\n const s = Math.max(vbW / rect.width, vbH / rect.height)\n const offsetX = (rect.width * s - vbW) / 2\n const offsetY = (rect.height * s - vbH) / 2\n return { x: minX - pad - offsetX + (client.x - rect.left) * s, y: minY - pad - offsetY + (client.y - rect.top) * s }\n }\n\n return (\n <svg\n data-slot=\"node-graph-minimap\"\n viewBox={viewBox}\n width={width}\n height={height}\n className={cn(\n \"absolute right-3 bottom-3 z-20 cursor-pointer rounded-lg border bg-background/90 shadow-sm backdrop-blur-sm\",\n className\n )}\n onPointerDown={(event) => {\n event.stopPropagation()\n event.preventDefault()\n const svg = event.currentTarget\n centerOn(pointToGraph(svg, { x: event.clientX, y: event.clientY }))\n const move = (e: PointerEvent) => centerOn(pointToGraph(svg, { x: e.clientX, y: e.clientY }))\n const up = () => {\n window.removeEventListener(\"pointermove\", move)\n window.removeEventListener(\"pointerup\", up)\n }\n window.addEventListener(\"pointermove\", move)\n window.addEventListener(\"pointerup\", up)\n }}\n {...props}\n >\n {nodes.map(([id, node]) => (\n <rect\n key={id}\n data-slot=\"node-graph-minimap-node\"\n data-selected={selection.nodes.includes(id) ? \"\" : undefined}\n x={node.position.x}\n y={node.position.y}\n width={node.width}\n height={node.height}\n rx={4 * scale}\n className=\"fill-muted-foreground/40 data-selected:fill-primary\"\n />\n ))}\n <rect\n data-slot=\"node-graph-minimap-viewport\"\n x={view.x}\n y={view.y}\n width={view.width}\n height={view.height}\n className=\"fill-primary/10 stroke-primary/60\"\n strokeWidth={scale}\n />\n </svg>\n )\n}\n\nfunction NodeGraphEmpty({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"node-graph-empty\"\n className={cn(\n \"pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 text-center text-sm text-muted-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\nexport {\n NodeGraph,\n NodeGraphViewport,\n NodeGraphBackground,\n NodeGraphSurface,\n NodeGraphEdges,\n NodeGraphEdge,\n NodeGraphConnectionLine,\n NodeGraphSelectionBox,\n NodeGraphNode,\n NodeGraphNodeHeader,\n NodeGraphNodeActions,\n NodeGraphNodeTitle,\n NodeGraphNodeSubtitle,\n NodeGraphNodeBody,\n NodeGraphNodeInputs,\n NodeGraphNodeOutputs,\n NodeGraphNodeFooter,\n NodeGraphPort,\n NodeGraphPortPin,\n NodeGraphPortLabel,\n NodeGraphControls,\n NodeGraphZoomInTrigger,\n NodeGraphZoomOutTrigger,\n NodeGraphFitViewTrigger,\n NodeGraphZoomValue,\n NodeGraphMinimap,\n NodeGraphEmpty,\n useNodeGraph,\n getBezierPath,\n type NodeGraphViewportState,\n type NodeGraphPortRef,\n type NodeGraphPortDetails,\n type NodeGraphPortSide,\n type NodeGraphConnection,\n type NodeGraphSelection,\n type NodeGraphNodeMove,\n}\n"
19
+ }
20
+ ],
21
+ "docs": "## Node graph\n`src/components/ui/node-graph.tsx` is a compositional, data-agnostic node editor (visual\nscripting style). The consumer owns nodes and edges; `NodeGraph` owns only the viewport\n(`viewport`/`onViewportChange`, pan/zoom), the selection (`selection`/`onSelectionChange`,\n`{ nodes, edges }`), and in-flight interactions. Intents: `onNodesMove` (live) +\n`onNodesMoveEnd` (once per drag/nudge), `onConnect({ source, target })` (source is always the\noutput side), `onConnectEnd({ source, position, client })` when released on empty space,\n`onDelete(selection)`, `onDisconnect(port)` (alt+click on a pin), and `onPane/Node/Edge/PortContextMenu`\n(right-click without a drag; a right-click on a node selects it first). `edgeAt(port)` enables\nre-routing: return the other end of the single link on a pin and dragging that pin picks the link\nup. `isValidConnection` overrides the built-in check (sides differ, nodes differ, port `type`s\nmatch or are `any`). Parts: `NodeGraphViewport` (pane, keyboard target: Delete/Backspace,\nEscape, ⌘A, arrows nudge by `snapGrid`, ⌘+/−/0; wheel zooms, pinch/ctrl always zooms,\n`panOnScroll` flips plain wheel to pan; middle/right/alt drag, Space+drag, or a single touch\npans, left drag on empty space is a marquee), `NodeGraphBackground` (`dots`/`lines`),\n`NodeGraphSurface` (transformed layer), `NodeGraphEdges` > `NodeGraphEdge` (`value`, `source`,\n`target`, render-prop children get the midpoint; `data-type` from the source port),\n`NodeGraphConnectionLine` (`data-type`, `data-valid`/`data-invalid`), `NodeGraphSelectionBox`,\n`NodeGraphNode` (`value`, `position`; draggable anywhere except interactive descendants,\nfocusable, Space/Enter toggles selection, double-click zooms to it; extra `data-*` props pass\nthrough, e.g. `data-active` for simulation highlights) with\n`Header/Title/Subtitle/Body/Inputs/Outputs/Footer` and `NodeGraphNodeActions` (header slot for\nbuttons, revealed on hover/selection, never starts a drag), `NodeGraphPort` (`value`, `side`,\n`type`, `connected`) > `NodeGraphPortPin` (`variant` `circle`|`exec`, colored via `text-*`,\nexposes `data-node-id/port-id/side/type/connected/target`) + `NodeGraphPortLabel`,\n`NodeGraphControls` > `NodeGraphZoomIn/ZoomOut/FitViewTrigger` + `NodeGraphZoomValue`,\n`NodeGraphMinimap` (click/drag to pan), `NodeGraphEmpty`, and `useNodeGraph()` (viewport,\nselection, `screenToGraph`, `fitView`, `zoomTo`, `centerOn`, ...). Port positions are measured\nfrom the DOM into an internal layout store; edges subscribe to it, so nodes need no size props.\n`src/demo/node-graph/` is the reference (\"Automations\", route `/automations`): typed pins\n(exec/number/string/boolean), category-tinted headers, literal inputs on unconnected data pins,\npalette (click or native drag), add-node Command popover from a dropped connection (filtered to\ncompatible nodes, auto-connects) or right-click on the pane, DropdownMenu context menus for nodes\n(run from here, duplicate ⌘D, break all links, delete), links (break) and pins (break, reset to\ndefault) anchored via `positioning.getAnchorRect`, a header \"more\" button on every node, undo/redo\nhistory on `src/lib/history.ts`, and a **simulator** (`simulate.ts`: async generator that walks\nexec pins from an event node, pulls data pins lazily, pure nodes evaluated on demand) driving\n`data-active` highlights (`--animate-node-graph-flow` dashes on the live edge), per-pin value\nbadges, a Delay progress bar, and an Output/Variables panel with Run/Stop and speed."
22
+ }