@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,25 @@
1
+ {
2
+ "name": "comment-thread",
3
+ "type": "ui",
4
+ "description": "CommentThread — compositional, data-agnostic comments with replies, reactions and a mentions composer. The consumer owns the comments and the people list; the thread owns only which comment is being replied to or edited. Mentions are plain ",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "lucide-react": "^1.41.0"
8
+ },
9
+ "registryDependencies": [
10
+ "avatar",
11
+ "button",
12
+ "controllable",
13
+ "kbd",
14
+ "popover",
15
+ "time",
16
+ "utils"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "components/ui/comment-thread.tsx",
21
+ "content": "import * as React from \"react\"\nimport { ark } from \"@ark-ui/react\"\nimport { CornerDownRightIcon, PencilIcon, SendIcon } from \"lucide-react\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\"\nimport { Button } from \"@/components/ui/button\"\nimport { Kbd } from \"@/components/ui/kbd\"\nimport { Popover, PopoverContent } from \"@/components/ui/popover\"\nimport { formatRelativeTime } from \"@/lib/time\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllable } from \"@/lib/controllable\"\n\n/**\n * CommentThread — compositional, data-agnostic comments with replies,\n * reactions and a mentions composer. The consumer owns the comments and\n * the people list; the thread owns only which comment is being replied to or\n * edited. Mentions are plain text (`@Full Name`); `extractMentions` maps\n * them back to ids and `CommentBody` highlights them.\n *\n * Anatomy:\n * CommentThread { people, currentUserId }\n * CommentList > Comment value= authorId=\n * CommentAvatar + CommentContent\n * CommentHeader > CommentAuthor, CommentTime date=, CommentMeta\n * CommentBody highlights @mentions\n * CommentReactions > CommentReaction emoji= count= active=\n * CommentActions > CommentReplyTrigger, CommentEditTrigger, …\n * CommentReplies > Comment … nested, with a guide line\n * CommentComposer { people, onSubmit }\n * CommentComposerReplyingTo \"Replying to X\" strip\n * CommentComposerInput textarea; type @ to mention, ⌘↵ to send\n * CommentComposerMentionList suggestions under the input\n * CommentComposerFooter > CommentComposerHint, CommentComposerCancelTrigger, CommentComposerSubmitTrigger\n */\n\ntype Person = { id: string; name: string; initials?: string; avatar?: string }\n\ntype ThreadContextValue = {\n people: Person[]\n currentUserId?: string\n replyTo: string | null\n setReplyTo: (id: string | null) => void\n editing: string | null\n setEditing: (id: string | null) => void\n}\nconst ThreadContext = React.createContext<ThreadContextValue | null>(null)\nconst CommentContext = React.createContext<{ value: string; authorId?: string; depth: number } | null>(null)\n\nfunction useCommentThread() {\n const ctx = React.useContext(ThreadContext)\n if (!ctx) throw new Error(\"Comment parts must be used within <CommentThread>\")\n return ctx\n}\nfunction useComment() {\n const ctx = React.useContext(CommentContext)\n if (!ctx) throw new Error(\"Comment parts must be used within <Comment>\")\n return ctx\n}\n\nconst initialsOf = (person?: Person) =>\n person?.initials ??\n (person?.name ?? \"?\")\n .split(/\\s+/)\n .map((p) => p[0])\n .join(\"\")\n .slice(0, 2)\n .toUpperCase()\n\n/* ---------------------------------------------------------------------------\n * Thread\n * ------------------------------------------------------------------------- */\n\nfunction CommentThread({\n people = [],\n currentUserId,\n replyTo: replyToProp,\n onReplyToChange,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & {\n people?: Person[]\n currentUserId?: string\n replyTo?: string | null\n onReplyToChange?: (id: string | null) => void\n}) {\n const [replyTo, setReplyTo] = useControllable<string | null>(replyToProp, null, onReplyToChange)\n const [editing, setEditing] = React.useState<string | null>(null)\n const ctx = React.useMemo(\n () => ({ people, currentUserId, replyTo, setReplyTo, editing, setEditing }),\n [people, currentUserId, replyTo, setReplyTo, editing]\n )\n return (\n <ThreadContext.Provider value={ctx}>\n <div data-slot=\"comment-thread\" className={cn(\"flex flex-col gap-4\", className)} {...props} />\n </ThreadContext.Provider>\n )\n}\n\nfunction CommentList({ className, ...props }: React.ComponentProps<\"ol\">) {\n return <ol data-slot=\"comment-list\" className={cn(\"flex flex-col gap-4\", className)} {...props} />\n}\n\nfunction Comment({\n value,\n authorId,\n className,\n ...props\n}: React.ComponentProps<\"li\"> & { value: string; authorId?: string }) {\n const thread = useCommentThread()\n const parent = React.useContext(CommentContext)\n const depth = parent ? parent.depth + 1 : 0\n const ctx = React.useMemo(() => ({ value, authorId, depth }), [value, authorId, depth])\n return (\n <CommentContext.Provider value={ctx}>\n <li\n data-slot=\"comment\"\n data-value={value}\n data-depth={depth}\n data-own={authorId && authorId === thread.currentUserId ? \"\" : undefined}\n data-replying={thread.replyTo === value ? \"\" : undefined}\n data-editing={thread.editing === value ? \"\" : undefined}\n className={cn(\"group/comment flex gap-3\", className)}\n {...props}\n />\n </CommentContext.Provider>\n )\n}\n\n/** Avatar for the comment's author (from `people`), or pass your own children. */\nfunction CommentAvatar({ className, children, ...props }: React.ComponentProps<typeof Avatar>) {\n const { people } = useCommentThread()\n const { authorId, depth } = useComment()\n const person = people.find((p) => p.id === authorId)\n return (\n <Avatar\n data-slot=\"comment-avatar\"\n size={depth > 0 ? \"sm\" : \"default\"}\n className={cn(\"shrink-0\", className)}\n {...props}\n >\n {children ?? (\n <>\n {person?.avatar && <AvatarImage src={person.avatar} alt=\"\" />}\n <AvatarFallback>{initialsOf(person)}</AvatarFallback>\n </>\n )}\n </Avatar>\n )\n}\n\nfunction CommentContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div data-slot=\"comment-content\" className={cn(\"flex min-w-0 flex-1 flex-col gap-1\", className)} {...props} />\n}\n\nfunction CommentHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"comment-header\"\n className={cn(\"flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm\", className)}\n {...props}\n />\n )\n}\n\n/** Author name from `people` by default. */\nfunction CommentAuthor({ className, children, ...props }: React.ComponentProps<\"span\">) {\n const { people } = useCommentThread()\n const { authorId } = useComment()\n return (\n <span data-slot=\"comment-author\" className={cn(\"font-medium\", className)} {...props}>\n {children ?? people.find((p) => p.id === authorId)?.name ?? \"Unknown\"}\n </span>\n )\n}\n\nfunction CommentTime({\n date,\n now,\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<\"time\">, \"dateTime\"> & { date: Date; now?: Date }) {\n return (\n <time\n data-slot=\"comment-time\"\n dateTime={date.toISOString()}\n title={date.toLocaleString()}\n className={cn(\"text-xs text-muted-foreground\", className)}\n {...props}\n >\n {children ?? formatRelativeTime(date, { now })}\n </time>\n )\n}\n\n/** Small trailing note, e.g. \"edited\" or a role badge. */\nfunction CommentMeta({ className, ...props }: React.ComponentProps<\"span\">) {\n return <span data-slot=\"comment-meta\" className={cn(\"text-xs text-muted-foreground\", className)} {...props} />\n}\n\n/** Split text into runs, turning `@Full Name` for known people into mention nodes. */\nfunction renderMentions(text: string, people: Person[]) {\n if (!people.length || !text.includes(\"@\")) return [text]\n const names = [...people].sort((a, b) => b.name.length - a.name.length)\n const pattern = new RegExp(\n `@(${names.map((p) => p.name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")).join(\"|\")})(?![\\\\w])`,\n \"g\"\n )\n const nodes: React.ReactNode[] = []\n let last = 0\n for (const match of text.matchAll(pattern)) {\n const index = match.index ?? 0\n if (index > last) nodes.push(text.slice(last, index))\n const person = people.find((p) => p.name === match[1])\n nodes.push(\n <CommentMention key={`${index}-${match[1]}`} personId={person?.id}>\n @{match[1]}\n </CommentMention>\n )\n last = index + match[0].length\n }\n if (last < text.length) nodes.push(text.slice(last))\n return nodes\n}\n\nfunction CommentMention({ personId, className, ...props }: React.ComponentProps<\"span\"> & { personId?: string }) {\n const { currentUserId } = useCommentThread()\n return (\n <span\n data-slot=\"comment-mention\"\n data-person={personId}\n data-self={personId && personId === currentUserId ? \"\" : undefined}\n className={cn(\n \"rounded-sm bg-primary/10 px-1 font-medium text-primary data-self:bg-amber-500/20 data-self:text-amber-700 dark:data-self:text-amber-300\",\n className\n )}\n {...props}\n />\n )\n}\n\n/** Plain text with mentions highlighted; pass `children` to render anything else. */\nfunction CommentBody({ text, className, children, ...props }: React.ComponentProps<\"div\"> & { text?: string }) {\n const { people } = useCommentThread()\n return (\n <div\n data-slot=\"comment-body\"\n className={cn(\"text-sm/relaxed wrap-break-word whitespace-pre-wrap\", className)}\n {...props}\n >\n {children ?? (text !== undefined ? renderMentions(text, people) : null)}\n </div>\n )\n}\n\nfunction CommentReactions({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"comment-reactions\"\n className={cn(\"flex flex-wrap items-center gap-1 pt-0.5\", className)}\n {...props}\n />\n )\n}\n\nfunction CommentReaction({\n emoji,\n count = 0,\n active = false,\n className,\n children,\n ...props\n}: React.ComponentProps<typeof ark.button> & { emoji: string; count?: number; active?: boolean }) {\n return (\n <ark.button\n type=\"button\"\n data-slot=\"comment-reaction\"\n data-active={active ? \"\" : undefined}\n aria-pressed={active}\n aria-label={`${emoji} ${count}`}\n className={cn(\n \"inline-flex h-6 items-center gap-1 rounded-full border bg-background px-1.5 text-xs transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none data-active:border-primary/40 data-active:bg-primary/10\",\n className\n )}\n {...props}\n >\n <span aria-hidden>{emoji}</span>\n {children ?? <span className=\"tabular-nums\">{count}</span>}\n </ark.button>\n )\n}\n\n/** Row of small text buttons; revealed on hover/focus, always visible when the comment is active. */\nfunction CommentActions({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"comment-actions\"\n className={cn(\n \"flex items-center gap-1 text-xs text-muted-foreground opacity-0 transition-opacity group-focus-within/comment:opacity-100 group-hover/comment:opacity-100 group-data-editing/comment:opacity-100 group-data-replying/comment:opacity-100\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction CommentReplyTrigger({ asChild, children, onClick, className, ...props }: React.ComponentProps<typeof Button>) {\n const { replyTo, setReplyTo } = useCommentThread()\n const { value } = useComment()\n return (\n <Button\n data-slot=\"comment-reply-trigger\"\n variant=\"ghost\"\n size=\"xs\"\n asChild={asChild}\n aria-pressed={replyTo === value}\n className={cn(\"h-6 px-1.5 text-xs\", className)}\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) setReplyTo(replyTo === value ? null : value)\n }}\n {...props}\n >\n {asChild\n ? children\n : (children ?? (\n <>\n <CornerDownRightIcon /> Reply\n </>\n ))}\n </Button>\n )\n}\n\nfunction CommentEditTrigger({ asChild, children, onClick, className, ...props }: React.ComponentProps<typeof Button>) {\n const { editing, setEditing } = useCommentThread()\n const { value } = useComment()\n return (\n <Button\n data-slot=\"comment-edit-trigger\"\n variant=\"ghost\"\n size=\"xs\"\n asChild={asChild}\n aria-pressed={editing === value}\n className={cn(\"h-6 px-1.5 text-xs\", className)}\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) setEditing(editing === value ? null : value)\n }}\n {...props}\n >\n {asChild\n ? children\n : (children ?? (\n <>\n <PencilIcon /> Edit\n </>\n ))}\n </Button>\n )\n}\n\n/** Nested replies with a guide line; put `Comment`s inside. */\nfunction CommentReplies({ className, ...props }: React.ComponentProps<\"ol\">) {\n return (\n <ol data-slot=\"comment-replies\" className={cn(\"mt-2 flex flex-col gap-3 border-s-2 ps-3\", className)} {...props} />\n )\n}\n\nfunction CommentEmpty({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"comment-empty\"\n className={cn(\"rounded-lg border border-dashed px-3 py-6 text-center text-sm text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Composer with mentions\n * ------------------------------------------------------------------------- */\n\ntype MentionState = { query: string; start: number; index: number } | null\n\ntype ComposerContextValue = {\n people: Person[]\n value: string\n setValue: (value: string) => void\n mention: MentionState\n setMention: (mention: MentionState) => void\n suggestions: Person[]\n pick: (person: Person) => void\n /** The input registers how a pick is applied to its DOM. */\n registerPicker: (fn: (person: Person, mention: NonNullable<MentionState>) => void) => () => void\n submit: () => void\n cancel: () => void\n canSubmit: boolean\n inputRef: React.RefObject<HTMLDivElement | null>\n listId: string\n}\nconst ComposerContext = React.createContext<ComposerContextValue | null>(null)\nfunction useComposer() {\n const ctx = React.useContext(ComposerContext)\n if (!ctx) throw new Error(\"Composer parts must be used within <CommentComposer>\")\n return ctx\n}\n\n/** Ids of people whose `@Full Name` appears in the text. */\nfunction extractMentions(text: string, people: Person[]) {\n return people\n .filter((p) => new RegExp(`@${p.name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}(?![\\\\w])`).test(text))\n .map((p) => p.id)\n}\n\nfunction CommentComposer({\n people = [],\n value: valueProp,\n defaultValue = \"\",\n onValueChange,\n onSubmit,\n onCancel,\n maxSuggestions = 6,\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"onSubmit\" | \"defaultValue\"> & {\n people?: Person[]\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n /** Called with the trimmed text and the ids of mentioned people. */\n onSubmit?: (comment: { text: string; mentions: string[] }) => void\n onCancel?: () => void\n maxSuggestions?: number\n}) {\n const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange)\n const [mention, setMention] = React.useState<MentionState>(null)\n const inputRef = React.useRef<HTMLDivElement>(null)\n const listId = React.useId()\n const pickerRef = React.useRef<((person: Person, mention: NonNullable<MentionState>) => void) | null>(null)\n const registerPicker = React.useCallback((fn: (person: Person, mention: NonNullable<MentionState>) => void) => {\n pickerRef.current = fn\n return () => {\n if (pickerRef.current === fn) pickerRef.current = null\n }\n }, [])\n\n const suggestions = React.useMemo(() => {\n if (!mention) return []\n const q = mention.query.toLowerCase()\n return people.filter((p) => p.name.toLowerCase().includes(q)).slice(0, maxSuggestions)\n }, [mention, people, maxSuggestions])\n\n const pick = React.useCallback(\n (person: Person) => {\n if (!mention) return\n pickerRef.current?.(person, mention)\n setMention(null)\n },\n [mention]\n )\n\n const canSubmit = value.trim().length > 0\n const submit = React.useCallback(() => {\n if (!canSubmit) return\n onSubmit?.({ text: value.trim(), mentions: extractMentions(value, people) })\n setValue(\"\")\n setMention(null)\n }, [canSubmit, onSubmit, value, people, setValue])\n const cancel = React.useCallback(() => {\n setValue(\"\")\n setMention(null)\n onCancel?.()\n }, [setValue, onCancel])\n\n const ctx = React.useMemo<ComposerContextValue>(\n () => ({\n people,\n value,\n setValue,\n mention,\n setMention,\n suggestions,\n pick,\n registerPicker,\n submit,\n cancel,\n canSubmit,\n inputRef,\n listId,\n }),\n [people, value, setValue, mention, suggestions, pick, registerPicker, submit, cancel, canSubmit, listId]\n )\n\n return (\n <ComposerContext.Provider value={ctx}>\n <div\n data-slot=\"comment-composer\"\n data-mentioning={mention ? \"\" : undefined}\n className={cn(\n \"relative flex flex-col gap-2 rounded-xl border bg-background p-2 transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50\",\n className\n )}\n {...props}\n >\n {children}\n </div>\n </ComposerContext.Provider>\n )\n}\n\n/** \"Replying to …\" strip; renders nothing when not replying. The render prop gets the comment id. */\nfunction CommentComposerReplyingTo({\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & { children?: (details: { id: string }) => React.ReactNode }) {\n const { replyTo, setReplyTo } = useCommentThread()\n if (!replyTo) return null\n return (\n <div\n data-slot=\"comment-composer-replying-to\"\n className={cn(\"flex items-center gap-2 px-1 text-xs text-muted-foreground\", className)}\n {...props}\n >\n <CornerDownRightIcon className=\"size-3.5\" />\n {children ? children({ id: replyTo }) : <span>Replying to a comment</span>}\n <Button variant=\"ghost\" size=\"xs\" className=\"ms-auto h-5 px-1.5\" onClick={() => setReplyTo(null)}>\n Cancel\n </Button>\n </div>\n )\n}\n\n/* ----- contenteditable helpers ----- */\n\nconst CHIP = \"comment-mention-chip\"\nconst escapeRe = (text: string) => text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")\n\n/** Serialise the editable DOM to plain text; chips become `@Full Name`, blocks and <br> become newlines. */\nfunction serialize(root: HTMLElement): string {\n let out = \"\"\n const walk = (node: Node) => {\n if (node.nodeType === Node.TEXT_NODE) {\n out += (node.textContent ?? \"\").replace(/ /g, \" \")\n return\n }\n if (!(node instanceof HTMLElement)) return\n if (node.dataset.slot === CHIP) {\n out += `@${node.dataset.name ?? \"\"}`\n return\n }\n if (node.tagName === \"BR\") {\n out += \"\\n\"\n return\n }\n const block = node.tagName === \"DIV\" || node.tagName === \"P\"\n if (block && out.length && !out.endsWith(\"\\n\")) out += \"\\n\"\n node.childNodes.forEach(walk)\n }\n root.childNodes.forEach(walk)\n return out.replace(/\\n$/, \"\")\n}\n\nfunction createChip(person: Person) {\n const chip = document.createElement(\"span\")\n chip.dataset.slot = CHIP\n chip.dataset.person = person.id\n chip.dataset.name = person.name\n chip.contentEditable = \"false\"\n chip.className =\n \"mx-px inline-flex items-center rounded-full bg-muted px-1.5 py-px align-baseline text-[0.9em] font-medium text-foreground select-none\"\n chip.textContent = `@${person.name}`\n return chip\n}\n\n/** Build editable DOM from plain text, turning known `@Full Name`s into chips. */\nfunction hydrate(root: HTMLElement, text: string, people: Person[]) {\n root.replaceChildren()\n const names = [...people].sort((a, b) => b.name.length - a.name.length)\n const pattern = names.length ? new RegExp(`@(${names.map((p) => escapeRe(p.name)).join(\"|\")})(?![\\\\w])`, \"g\") : null\n text.split(\"\\n\").forEach((line, lineIndex) => {\n if (lineIndex > 0) root.appendChild(document.createElement(\"br\"))\n let last = 0\n if (pattern) {\n for (const match of line.matchAll(pattern)) {\n const index = match.index ?? 0\n if (index > last) root.appendChild(document.createTextNode(line.slice(last, index)))\n const person = people.find((p) => p.name === match[1])\n if (person) root.appendChild(createChip(person))\n last = index + match[0].length\n }\n }\n if (last < line.length) root.appendChild(document.createTextNode(line.slice(last)))\n })\n}\n\n/** The `@query` immediately before the caret inside a text node, if any. */\nfunction mentionAtCaret(root: HTMLElement): { node: Text; start: number; end: number; query: string } | null {\n const selection = window.getSelection()\n if (!selection || !selection.rangeCount || !selection.isCollapsed) return null\n const range = selection.getRangeAt(0)\n const node = range.startContainer\n if (node.nodeType !== Node.TEXT_NODE || !root.contains(node)) return null\n const text = node.textContent ?? \"\"\n const caret = range.startOffset\n const before = text.slice(0, caret)\n const at = before.lastIndexOf(\"@\")\n if (at === -1) return null\n if (at > 0 && /\\S/.test(before[at - 1])) return null\n const query = before.slice(at + 1)\n if (query.length > 30) return null\n return { node: node as Text, start: at, end: caret, query }\n}\n\nfunction placeCaretAfter(node: Node) {\n const selection = window.getSelection()\n if (!selection) return\n const range = document.createRange()\n range.setStartAfter(node)\n range.collapse(true)\n selection.removeAllRanges()\n selection.addRange(range)\n}\n\n/**\n * Contenteditable input: picked mentions become atomic grey chips (one\n * Backspace removes a whole chip); `value` stays plain `@Full Name` text.\n * Arrows/Enter/Tab pick from the list, Escape closes it, ⌘/Ctrl+Enter submits.\n */\nfunction CommentComposerInput({\n className,\n placeholder = \"Write a comment…\",\n autoFocus,\n onKeyDown,\n onBlur,\n onInput,\n onPaste,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"onChange\"> & { placeholder?: string; autoFocus?: boolean }) {\n const c = useComposer()\n const lastSerialized = React.useRef<string | null>(null)\n\n // Reflect outside changes to `value` (initial text, edits, reset after submit) without disturbing typing.\n React.useLayoutEffect(() => {\n const el = c.inputRef.current\n if (!el || c.value === lastSerialized.current) return\n hydrate(el, c.value, c.people)\n lastSerialized.current = c.value\n }, [c.value, c.people, c.inputRef])\n\n React.useEffect(() => {\n if (autoFocus) c.inputRef.current?.focus()\n }, [autoFocus, c.inputRef])\n\n const { registerPicker, setValue, inputRef } = c\n React.useEffect(\n () =>\n registerPicker((person) => {\n const el = inputRef.current\n if (!el) return\n const hit = mentionAtCaret(el)\n const chip = createChip(person)\n const space = document.createTextNode(\" \")\n if (hit) {\n const range = document.createRange()\n range.setStart(hit.node, hit.start)\n range.setEnd(hit.node, hit.end)\n range.deleteContents()\n range.insertNode(space)\n range.insertNode(chip)\n } else {\n el.append(chip, space)\n }\n placeCaretAfter(space)\n el.focus()\n const next = serialize(el)\n lastSerialized.current = next\n setValue(next)\n }),\n [registerPicker, inputRef, setValue]\n )\n\n const sync = () => {\n const el = c.inputRef.current\n if (!el) return\n if (el.innerHTML === \"<br>\") el.replaceChildren()\n const next = serialize(el)\n lastSerialized.current = next\n c.setValue(next)\n const hit = mentionAtCaret(el)\n c.setMention(hit ? { query: hit.query, start: hit.start, index: 0 } : null)\n }\n\n return (\n <div\n ref={c.inputRef}\n data-slot=\"comment-composer-input\"\n role={c.mention ? \"combobox\" : \"textbox\"}\n contentEditable\n suppressContentEditableWarning\n aria-multiline={c.mention ? undefined : true}\n aria-expanded={c.mention ? true : undefined}\n aria-controls={c.mention ? c.listId : undefined}\n aria-autocomplete={c.mention ? \"list\" : undefined}\n aria-activedescendant={\n c.mention && c.suggestions[c.mention.index] ? `${c.listId}-${c.suggestions[c.mention.index].id}` : undefined\n }\n data-placeholder={placeholder}\n className={cn(\n \"min-h-20 px-2 py-1.5 text-sm wrap-break-word whitespace-pre-wrap outline-none\",\n \"empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]\",\n className\n )}\n onInput={(event) => {\n onInput?.(event)\n sync()\n }}\n onKeyUp={(event) => {\n if ([\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].includes(event.key)) sync()\n }}\n onClick={() => sync()}\n onPaste={(event) => {\n onPaste?.(event)\n if (event.defaultPrevented) return\n event.preventDefault()\n document.execCommand(\"insertText\", false, event.clipboardData.getData(\"text/plain\"))\n }}\n onKeyDown={(event) => {\n onKeyDown?.(event)\n if (event.defaultPrevented) return\n if ((event.metaKey || event.ctrlKey) && event.key === \"Enter\") {\n event.preventDefault()\n c.submit()\n return\n }\n if (!c.mention) {\n if (event.key === \"Escape\") c.cancel()\n return\n }\n const count = c.suggestions.length\n if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n event.preventDefault()\n if (!count) return\n const delta = event.key === \"ArrowDown\" ? 1 : -1\n c.setMention({ ...c.mention, index: (c.mention.index + delta + count) % count })\n } else if ((event.key === \"Enter\" || event.key === \"Tab\") && count) {\n event.preventDefault()\n c.pick(c.suggestions[c.mention.index])\n } else if (event.key === \"Escape\") {\n event.preventDefault()\n c.setMention(null)\n }\n }}\n onBlur={(event) => {\n onBlur?.(event)\n setTimeout(() => {\n if (document.activeElement !== event.target) c.setMention(null)\n }, 120)\n }}\n {...props}\n />\n )\n}\n\n/** Floating suggestion list anchored under the input while an `@query` is active. Focus stays in the textarea. */\nfunction CommentComposerMentionList({ className, ...props }: React.ComponentProps<typeof PopoverContent>) {\n const c = useComposer()\n const open = !!c.mention\n return (\n <Popover\n open={open}\n onOpenChange={({ open }) => {\n if (!open) c.setMention(null)\n }}\n autoFocus={false}\n modal={false}\n positioning={{\n placement: \"bottom-start\",\n sameWidth: true,\n gutter: 4,\n getAnchorRect: () => {\n const rect = c.inputRef.current?.getBoundingClientRect()\n return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null\n },\n }}\n >\n <PopoverContent\n id={c.listId}\n role=\"listbox\"\n aria-label=\"People\"\n data-slot=\"comment-composer-mention-list\"\n className={cn(\"max-h-56 w-auto overflow-y-auto p-1\", className)}\n onPointerDown={(event) => event.preventDefault()}\n {...props}\n >\n {c.suggestions.length === 0 ? (\n <div className=\"px-2 py-1.5 text-sm text-muted-foreground\">No one matches \"{c.mention?.query}\"</div>\n ) : (\n c.suggestions.map((person, index) => (\n <div\n key={person.id}\n id={`${c.listId}-${person.id}`}\n role=\"option\"\n aria-selected={index === c.mention?.index}\n data-slot=\"comment-composer-mention-item\"\n data-highlighted={index === c.mention?.index ? \"\" : undefined}\n className=\"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm data-highlighted:bg-accent data-highlighted:text-accent-foreground\"\n onMouseEnter={() => c.mention && c.setMention({ ...c.mention, index })}\n onClick={() => c.pick(person)}\n >\n <Avatar size=\"sm\" className=\"size-6 text-[10px]\">\n {person.avatar && <AvatarImage src={person.avatar} alt=\"\" />}\n <AvatarFallback>{initialsOf(person)}</AvatarFallback>\n </Avatar>\n <span className=\"truncate\">{person.name}</span>\n </div>\n ))\n )}\n </PopoverContent>\n </Popover>\n )\n}\n\nfunction CommentComposerFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div data-slot=\"comment-composer-footer\" className={cn(\"flex items-center gap-2 px-1\", className)} {...props} />\n )\n}\n\nfunction CommentComposerHint({ className, children, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"comment-composer-hint\"\n className={cn(\"me-auto text-xs text-muted-foreground\", className)}\n {...props}\n >\n {children ?? (\n <>\n <Kbd>@</Kbd> to mention · <Kbd>⌘↵</Kbd> to send\n </>\n )}\n </span>\n )\n}\n\nfunction CommentComposerSubmitTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {\n const { submit, canSubmit } = useComposer()\n return (\n <Button\n data-slot=\"comment-composer-submit-trigger\"\n size=\"sm\"\n asChild={asChild}\n disabled={!canSubmit}\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) submit()\n }}\n {...props}\n >\n {asChild\n ? children\n : (children ?? (\n <>\n <SendIcon /> Comment\n </>\n ))}\n </Button>\n )\n}\n\nfunction CommentComposerCancelTrigger({ asChild, children, onClick, ...props }: React.ComponentProps<typeof Button>) {\n const { cancel } = useComposer()\n return (\n <Button\n data-slot=\"comment-composer-cancel-trigger\"\n variant=\"ghost\"\n size=\"sm\"\n asChild={asChild}\n onClick={(event) => {\n onClick?.(event)\n if (!event.defaultPrevented) cancel()\n }}\n {...props}\n >\n {asChild ? children : (children ?? \"Cancel\")}\n </Button>\n )\n}\n\nexport {\n CommentThread,\n CommentList,\n Comment,\n CommentAvatar,\n CommentContent,\n CommentHeader,\n CommentAuthor,\n CommentTime,\n CommentMeta,\n CommentBody,\n CommentMention,\n CommentReactions,\n CommentReaction,\n CommentActions,\n CommentReplyTrigger,\n CommentEditTrigger,\n CommentReplies,\n CommentEmpty,\n CommentComposer,\n CommentComposerReplyingTo,\n CommentComposerInput,\n CommentComposerMentionList,\n CommentComposerFooter,\n CommentComposerHint,\n CommentComposerSubmitTrigger,\n CommentComposerCancelTrigger,\n useCommentThread,\n useComment,\n extractMentions,\n renderMentions,\n type Person as CommentPerson,\n}\n"
22
+ }
23
+ ],
24
+ "docs": "## Activity feed and comments\n`src/components/ui/activity-feed.tsx` is a compositional timeline of events on the WAI-ARIA feed\npattern (`role=\"feed\"`, focusable articles, PageUp/PageDown move, Ctrl+Home/End jump):\n`ActivityFeed` > `ActivityFeedGroup` > `ActivityFeedGroupLabel` + `ActivityFeedItems` (draws the\nconnector line) > `ActivityFeedItem value=` > `ActivityFeedItemMarker` (dot by default; pass an\nicon or an Avatar) + `ActivityFeedItemContent` > `ActivityFeedItemHeader` > `ActivityFeedItemActor`,\n`ActivityFeedItemTime date=` (`<time>` with a relative label from `src/lib/time.ts`\n`formatRelativeTime`; full date in the title), `ActivityFeedItemBody`, `ActivityFeedItemActions`;\nplus `ActivityFeedLoadMoreTrigger` and `ActivityFeedEmpty`. Data, grouping and read state are the\nconsumer's.\n`src/components/ui/comment-thread.tsx` is compositional comments with replies, reactions and a\nmentions composer. `CommentThread { people, currentUserId, replyTo/onReplyToChange }` owns only\nwhich comment is being replied to or edited (`useCommentThread()` → `replyTo`, `editing`, setters).\nParts: `CommentList` > `Comment value= authorId=` (`data-own/replying/editing/depth`) >\n`CommentAvatar` (initials from `people`) + `CommentContent` > `CommentHeader` > `CommentAuthor`,\n`CommentTime date=`, `CommentMeta`; `CommentBody text=` (plain text with `@Full Name` mentions\nhighlighted as `CommentMention`, `data-self` for the current user); `CommentReactions` >\n`CommentReaction emoji= count= active=`; `CommentActions` (hover-revealed) > `CommentReplyTrigger`,\n`CommentEditTrigger`; `CommentReplies` (nested list with a guide line); `CommentEmpty`.\n`CommentComposer { people, value/onValueChange, onSubmit({ text, mentions }), onCancel }` >\n`CommentComposerReplyingTo` (render prop gets the comment id), `CommentComposerInput` (a\ncontenteditable, not a textarea: picked mentions become atomic grey chips\n`[data-slot=comment-mention-chip]` that one Backspace removes, while `value` stays plain\n`@Full Name` text and `defaultValue`/`value` text is hydrated back into chips;\n`@query` at the caret opens `CommentComposerMentionList` (a portaled Popover anchored to the input, focus stays in the textarea, so no `overflow-hidden` ancestor clips it and nothing shifts), arrows/Enter/Tab pick, Escape closes,\n⌘/Ctrl+Enter submits, `aria-activedescendant` wired), `CommentComposerFooter` >\n`CommentComposerHint`, `CommentComposerCancelTrigger`, `CommentComposerSubmitTrigger`. Mentions are\nplain `@Full Name` text; `extractMentions(text, people)` returns ids. The same composer edits in\nplace (`defaultValue`, Save). `src/demo/activity/` is the reference (`/activity`): grouped feed\nwith All/Mentions/Comments filter, unread dots, load more, and a ticket thread with nested\nreplies, reactions, edit/delete of own comments (Popconfirm), and posting that also prepends a\nfeed item."
25
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "context-menu",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "lucide-react": "^1.41.0"
8
+ },
9
+ "registryDependencies": [
10
+ "utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/context-menu.tsx",
15
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { Menu as ContextMenuPrimitive, Portal as PortalPrimitive } from \"@ark-ui/react\"\nimport { CheckIcon, ChevronRightIcon, ChevronDownIcon } from \"lucide-react\"\n\nfunction ContextMenu({\n lazyMount = true,\n unmountOnExit = true,\n ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {\n return <ContextMenuPrimitive.Root lazyMount={lazyMount} unmountOnExit={unmountOnExit} {...props} />\n}\n\nfunction ContextMenuTrigger({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.ContextTrigger>) {\n return (\n <ContextMenuPrimitive.ContextTrigger\n data-slot=\"context-menu-trigger\"\n className={cn(\"select-none\", className)}\n {...props}\n />\n )\n}\n\nfunction ContextMenuPortal({ ...props }: React.ComponentProps<typeof PortalPrimitive>) {\n return <PortalPrimitive {...props} />\n}\n\nfunction ContextMenuContext({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Context>) {\n return <ContextMenuPrimitive.Context {...props} />\n}\n\nfunction ContextMenuPositioner({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Positioner>) {\n return (\n <ContextMenuPrimitive.Positioner\n data-slot=\"context-menu-positioner\"\n className={cn(\"[--z-index:50]\", className)}\n {...props}\n />\n )\n}\n\nfunction ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {\n return (\n <ContextMenuPortal>\n <ContextMenuPositioner>\n <ContextMenuPrimitive.Content\n data-slot=\"context-menu-content\"\n className={cn(\n \"z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto 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 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\",\n className\n )}\n {...props}\n />\n </ContextMenuPositioner>\n </ContextMenuPortal>\n )\n}\n\nfunction ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.ItemGroup>) {\n return <ContextMenuPrimitive.ItemGroup data-slot=\"context-menu-group\" {...props} />\n}\n\nfunction ContextMenuItem({\n className,\n inset,\n variant = \"default\",\n value,\n ...props\n}: Omit<React.ComponentProps<typeof ContextMenuPrimitive.Item>, \"value\"> & {\n value?: string\n inset?: boolean\n variant?: \"default\" | \"destructive\"\n}) {\n const id = React.useId()\n return (\n <ContextMenuPrimitive.Item\n data-slot=\"context-menu-item\"\n data-inset={inset}\n data-variant={variant}\n value={value ?? id}\n className={cn(\n \"group/context-menu-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 ContextMenuItemText({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.ItemText>) {\n return <ContextMenuPrimitive.ItemText data-slot=\"context-menu-item-text\" {...props} />\n}\n\nfunction ContextMenuItemIndicator({\n className,\n ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.ItemIndicator>) {\n return (\n <ContextMenuPrimitive.ItemIndicator\n data-slot=\"context-menu-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 ContextMenuCheckboxItem({\n className,\n children,\n checked = false,\n inset,\n value,\n ...props\n}: Omit<React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>, \"checked\" | \"value\"> & {\n checked?: boolean\n value?: string\n inset?: boolean\n}) {\n const id = React.useId()\n return (\n <ContextMenuPrimitive.CheckboxItem\n data-slot=\"context-menu-checkbox-item\"\n data-inset={inset}\n className={cn(optionItemClassName, className)}\n checked={checked}\n value={value ?? id}\n {...props}\n >\n <ContextMenuItemIndicator data-slot=\"context-menu-checkbox-item-indicator\">\n <CheckIcon />\n </ContextMenuItemIndicator>\n {children}\n </ContextMenuPrimitive.CheckboxItem>\n )\n}\n\nfunction ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItemGroup>) {\n return <ContextMenuPrimitive.RadioItemGroup data-slot=\"context-menu-radio-group\" {...props} />\n}\n\nfunction ContextMenuRadioItem({\n className,\n children,\n inset,\n ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {\n inset?: boolean\n}) {\n return (\n <ContextMenuPrimitive.RadioItem\n data-slot=\"context-menu-radio-item\"\n data-inset={inset}\n className={cn(optionItemClassName, className)}\n {...props}\n >\n <ContextMenuItemIndicator data-slot=\"context-menu-radio-item-indicator\">\n <CheckIcon />\n </ContextMenuItemIndicator>\n {children}\n </ContextMenuPrimitive.RadioItem>\n )\n}\n\nfunction ContextMenuLabel({\n className,\n inset,\n ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.ItemGroupLabel> & {\n inset?: boolean\n}) {\n return (\n <ContextMenuPrimitive.ItemGroupLabel\n data-slot=\"context-menu-label\"\n data-inset={inset}\n className={cn(\"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7\", className)}\n {...props}\n />\n )\n}\n\nfunction ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {\n return (\n <ContextMenuPrimitive.Separator\n data-slot=\"context-menu-separator\"\n className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n {...props}\n />\n )\n}\n\nfunction ContextMenuShortcut({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"context-menu-shortcut\"\n className={cn(\n \"ml-auto text-xs tracking-widest text-muted-foreground group-data-highlighted/context-menu-item:text-accent-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction ContextMenuSub({\n lazyMount = true,\n unmountOnExit = true,\n ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {\n return <ContextMenuPrimitive.Root lazyMount={lazyMount} unmountOnExit={unmountOnExit} {...props} />\n}\n\nfunction ContextMenuSubTrigger({\n className,\n inset,\n children,\n ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.TriggerItem> & {\n inset?: boolean\n}) {\n return (\n <ContextMenuPrimitive.TriggerItem\n data-slot=\"context-menu-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 </ContextMenuPrimitive.TriggerItem>\n )\n}\n\nfunction ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {\n return (\n <ContextMenuPortal>\n <ContextMenuPositioner>\n <ContextMenuPrimitive.Content\n data-slot=\"context-menu-sub-content\"\n className={cn(\n \"z-50 min-w-32 origin-(--transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg 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 </ContextMenuPositioner>\n </ContextMenuPortal>\n )\n}\n\n/** Render-prop access to one item's state (`{ selected, highlighted, disabled, ... }`). */\nfunction ContextMenuItemContext({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.ItemContext>) {\n return <ContextMenuPrimitive.ItemContext {...props} />\n}\n\n/** A click trigger for the same menu, alongside the right-click `ContextMenuTrigger`. */\nfunction ContextMenuClickTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {\n return <ContextMenuPrimitive.Trigger data-slot=\"context-menu-click-trigger\" {...props} />\n}\n\nfunction ContextMenuIndicator({\n className,\n children,\n ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Indicator>) {\n return (\n <ContextMenuPrimitive.Indicator\n data-slot=\"context-menu-indicator\"\n className={cn(\"inline-flex transition-transform data-[state=open]:rotate-180 [&_svg]:size-4\", className)}\n {...props}\n >\n {children ?? <ChevronDownIcon />}\n </ContextMenuPrimitive.Indicator>\n )\n}\n\nfunction ContextMenuArrow({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Arrow>) {\n return (\n <ContextMenuPrimitive.Arrow\n data-slot=\"context-menu-arrow\"\n className={cn(\"[--arrow-background:var(--color-popover)] [--arrow-size:0.625rem]\", className)}\n {...props}\n >\n <ContextMenuArrowTip />\n </ContextMenuPrimitive.Arrow>\n )\n}\n\nfunction ContextMenuArrowTip({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.ArrowTip>) {\n return (\n <ContextMenuPrimitive.ArrowTip\n data-slot=\"context-menu-arrow-tip\"\n className={cn(\"border-t border-l border-foreground/10\", className)}\n {...props}\n />\n )\n}\n\nexport {\n ContextMenu,\n ContextMenuCheckboxItem,\n ContextMenuContent,\n ContextMenuContext,\n ContextMenuGroup,\n ContextMenuItem,\n ContextMenuItemIndicator,\n ContextMenuItemText,\n ContextMenuLabel,\n ContextMenuPortal,\n ContextMenuPositioner,\n ContextMenuRadioGroup,\n ContextMenuRadioItem,\n ContextMenuSeparator,\n ContextMenuShortcut,\n ContextMenuSub,\n ContextMenuSubContent,\n ContextMenuSubTrigger,\n ContextMenuTrigger,\n ContextMenuItemContext,\n ContextMenuArrow,\n ContextMenuArrowTip,\n ContextMenuClickTrigger,\n ContextMenuIndicator,\n}\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "controllable",
3
+ "type": "lib",
4
+ "description": "Both live on Ark's `TreeCollection` (`createTreeCollection`, re-exported) and a Popover, and own only the open state, the search query, and the value (`value`/`defaultValue`/`onValueChange`, always a `string[]`); the trigger is a focusable ",
5
+ "dependencies": {},
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "lib/controllable.ts",
10
+ "content": "import * as React from \"react\"\n\ntype Updater<T> = T | ((prev: T) => T)\n\n/**\n * Controlled-or-uncontrolled state. Pass the controlled value (or `undefined`), the\n * uncontrolled default, and an optional change callback that fires in both modes.\n */\nexport function useControllable<T>(\n controlled: T | undefined,\n defaultValue: T,\n onChange?: (value: T) => void\n): [T, (next: Updater<T>) => void] {\n const [internal, setInternal] = React.useState<T>(defaultValue)\n const isControlled = controlled !== undefined\n const value = isControlled ? (controlled as T) : internal\n const valueRef = React.useRef(value)\n valueRef.current = value\n const onChangeRef = React.useRef(onChange)\n onChangeRef.current = onChange\n const set = React.useCallback(\n (updater: Updater<T>) => {\n const next = typeof updater === \"function\" ? (updater as (prev: T) => T)(valueRef.current) : updater\n if (Object.is(next, valueRef.current)) return\n if (!isControlled) setInternal(next)\n onChangeRef.current?.(next)\n },\n [isControlled]\n )\n return [value, set]\n}\n"
11
+ }
12
+ ],
13
+ "docs": "## Tree select and cascader\nBoth live on Ark's `TreeCollection` (`createTreeCollection`, re-exported) and a Popover, and own\nonly the open state, the search query, and the value (`value`/`defaultValue`/`onValueChange`,\nalways a `string[]`); the trigger is a focusable `div[role=combobox]` via `PopoverTrigger asChild`\nso chips and clear buttons inside it stay real buttons. Both use `src/lib/controllable.ts`\n`useControllable(controlled, default, onChange)` (the shared controllable-state hook; prefer it\nover local copies).\n`src/components/ui/tree-select.tsx`: `TreeSelect { collection, multiple, selectableBranches,\ncloseOnSelect, expandedValue…, open…, disabled, readOnly, invalid }` > `TreeSelectTrigger`\n(`size`, `variant=\"unstyled\"`; Enter/Space/ArrowDown open, Backspace removes the last chip) >\n`TreeSelectValue` (labels joined, render-prop child gets the nodes) or `TreeSelectChips` >\n`TreeSelectChip node=` (remove button), `TreeSelectClearTrigger`, `TreeSelectIndicator`;\n`TreeSelectContent` (same width as the trigger) > `TreeSelectSearch` (filters the collection with\nancestors kept and auto-expands matches; ArrowDown moves into the tree), `TreeSelectTree`\n(renders the toolkit `TreeView` parts recursively through `TreeSelectNode`; render-prop child\ncustomises the row label), `TreeSelectEmpty`; `TreeSelectHiddenInput name=`. Multiple mode is\nArk checkbox semantics: `value` holds leaf values only, a branch toggles all its leaves, and\n`collapseValues(collection, value)` (used by the value/chips) shows a fully selected branch as one\nchip. Single mode ignores branch picks unless `selectableBranches`. `useTreeSelect()` exposes it all.\n`src/components/ui/cascader.tsx`: `Cascader { collection, changeOnSelect, expandTrigger:\nclick|hover, open…, disabled, readOnly, invalid }` > `CascaderTrigger` > `CascaderValue`\n(the path as \"A / B / C\", `separator`, render-prop child gets the path nodes), `CascaderClearTrigger`,\n`CascaderIndicator`; `CascaderContent` (owns the keyboard: ↑/↓ within a column, → opens a branch,\n← back to the parent, Home/End, Enter/Space activates) > `CascaderSearch` (matches every level,\nflat results replace the columns), `CascaderColumns` (`role=\"tree\"`, `aria-activedescendant`;\ncolumn `i + 1` lists the children of `expandedPath[i]`; render-prop child customises items) >\n`CascaderColumn depth=` > `CascaderItem node=` (`data-branch/expanded/selected/highlighted/disabled`)\n> `CascaderItemText` + `CascaderItemIndicator` (chevron on branches, check on the selected leaf),\n`CascaderSearchResults` > `CascaderSearchResult path=`, `CascaderEmpty`, `CascaderHiddenInput`.\nPicking a leaf closes; a branch opens the next column and, with `changeOnSelect`, is itself the\nvalue. Opening starts from the current value's path. `useCascader()` exposes it all.\n`src/demo/pickers/` is the reference (`/access`, \"Roles & access\"): folder picker with branches selectable and\nicons, people/teams multi-select with chips, hover-expanding location cascader with search, and a\nchange-on-select category cascader."
14
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "data-grid",
3
+ "type": "ui",
4
+ "description": "`src/components/ui/data-grid.tsx` is an editable, keyboard-driven grid on the same source-agnostic `DataTableInstance` contract as the data table (`useDataTable` with a huge `pageSize` feeds it every row; `DataTable` toolbar parts can wrap ",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "lucide-react": "^1.41.0"
8
+ },
9
+ "registryDependencies": [
10
+ "checkbox",
11
+ "controllable",
12
+ "data-table-adapter",
13
+ "live-region",
14
+ "select",
15
+ "utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/data-grid.tsx",
20
+ "content": "import * as React from \"react\"\nimport { ark } from \"@ark-ui/react\"\nimport { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from \"lucide-react\"\nimport { createListCollection } from \"@ark-ui/react/collection\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport {\n Select,\n SelectContent,\n SelectControl,\n SelectItem,\n SelectItemIndicator,\n SelectItemText,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\"\nimport type { DataTableInstance, DataTableRow } from \"@/lib/data-table-adapter\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllable } from \"@/lib/controllable\"\nimport { LiveRegion, useLiveRegion } from \"@/components/ui/live-region\"\n\n/**\n * DataGrid — an editable, keyboard-driven grid on the same source-agnostic\n * `DataTableInstance` contract as the data table. The instance supplies rows,\n * columns, sorting, filtering and selection; the grid adds cell focus, range\n * selection, inline editing, copy/paste, row virtualization, column resizing\n * and pinning. Edits are intents: `onCellChange({ row, columnId, value })`\n * and the consumer owns the data.\n *\n * Column behaviour is data (`columns` prop, keyed by column id):\n * `{ id, type, editable, options, width, minWidth, pinned, format, parse }`.\n *\n * Anatomy:\n * DataGrid { table, columns, onCellChange }\n * DataGridContainer scroll viewport, role=\"grid\"\n * DataGridHeader > DataGridHeaderRow > DataGridHead column=… (+ DataGridResizeHandle)\n * DataGridBody children={(row) => cells} virtualized rows\n * DataGridRow > DataGridCell column=… focusable, editable\n * DataGridEmpty\n * DataGridSelectAll / DataGridSelectRow checkbox cells\n *\n * Keyboard: arrows move, Shift+arrows extend the range, Home/End and\n * Ctrl+Home/End jump, PageUp/PageDown scroll, Enter or F2 or typing edits,\n * Enter commits and moves down, Tab commits and moves right, Escape cancels,\n * Space toggles booleans, Delete clears the range, ⌘C/⌘V copy and paste TSV.\n */\n\ntype DataGridColumnType = \"text\" | \"number\" | \"boolean\" | \"select\" | \"date\"\ntype DataGridOption = { value: string; label: string }\ntype DataGridColumnConfig = {\n id: string\n type?: DataGridColumnType\n editable?: boolean\n options?: DataGridOption[]\n width?: number\n minWidth?: number\n pinned?: \"left\" | \"right\"\n /** Value → text for display and for seeding the editor. */\n format?: (value: unknown) => string\n /** Editor text → value on commit. */\n parse?: (input: string, previous: unknown) => unknown\n}\n\ntype CellAddress = { rowIndex: number; columnId: string }\ntype CellRange = { anchor: CellAddress; focus: CellAddress }\ntype DataGridCellChange<TData> = {\n row: DataTableRow<TData>\n columnId: string\n value: unknown\n previous: unknown\n}\n\ntype EditorRenderProps = {\n value: string\n onChange: (value: string) => void\n commit: (value?: string) => void\n cancel: () => void\n column: DataGridColumnConfig\n row: DataTableRow<unknown>\n inputProps: React.InputHTMLAttributes<HTMLInputElement> & { ref: React.Ref<HTMLInputElement> }\n}\n\nconst DEFAULT_WIDTH = 160\nconst MIN_WIDTH = 60\n\n/* ---------------------------------------------------------------------------\n * Context\n * ------------------------------------------------------------------------- */\n\ntype ColumnLayout = {\n id: string\n width: number\n pinned?: \"left\" | \"right\"\n /** Sticky offset for pinned columns. */\n offset: number\n config: DataGridColumnConfig\n}\n\ntype DataGridContextValue<TData = unknown> = {\n table: DataTableInstance<TData>\n rows: DataTableRow<TData>[]\n layout: ColumnLayout[]\n layoutById: Map<string, ColumnLayout>\n templateColumns: string\n totalWidth: number\n rowHeight: number\n setWidth: (columnId: string, width: number) => void\n focus: CellAddress | null\n range: CellRange | null\n editing: (CellAddress & { seed?: string }) | null\n focusCell: (address: CellAddress, options?: { extend?: boolean; scroll?: boolean }) => void\n startEditing: (address: CellAddress, seed?: string) => void\n stopEditing: (options?: { refocus?: boolean }) => void\n commit: (address: CellAddress, value: unknown) => void\n clearRange: () => void\n copyRange: () => Promise<void>\n paste: () => Promise<void>\n isInRange: (address: CellAddress) => boolean\n containerRef: React.RefObject<HTMLDivElement | null>\n /** Bumped whenever a cell should take DOM focus; cells act on it in a layout effect. */\n focusRequest: number\n /** Editors register so the grid can close them when the viewport scrolls. */\n registerEditor: (handle: { close: () => void }) => () => void\n closeEditor: () => void\n window: { start: number; end: number }\n setScrollTop: (top: number) => void\n setViewportHeight: (height: number) => void\n announce: (message: string) => void\n}\n\nconst DataGridContext = React.createContext<DataGridContextValue | null>(null)\nconst RowContext = React.createContext<{ row: DataTableRow<unknown>; index: number } | null>(null)\n\nfunction useDataGrid<TData = unknown>() {\n const ctx = React.useContext(DataGridContext)\n if (!ctx) throw new Error(\"DataGrid parts must be used within <DataGrid>\")\n return ctx as DataGridContextValue<TData>\n}\nfunction useDataGridRow() {\n const ctx = React.useContext(RowContext)\n if (!ctx) throw new Error(\"DataGridCell must be used within <DataGridRow>\")\n return ctx\n}\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))\nconst defaultFormat = (value: unknown) => {\n if (value === null || value === undefined) return \"\"\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? \"\" : value.toISOString().slice(0, 10)\n return String(value)\n}\n\n/* ---------------------------------------------------------------------------\n * Root\n * ------------------------------------------------------------------------- */\n\nfunction DataGrid<TData>({\n table,\n columns = [],\n rowHeight = 36,\n overscan = 6,\n onCellChange,\n columnSizing: sizingProp,\n defaultColumnSizing,\n onColumnSizingChange,\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n table: DataTableInstance<TData>\n columns?: DataGridColumnConfig[]\n rowHeight?: number\n overscan?: number\n onCellChange?: (change: DataGridCellChange<TData>) => void\n columnSizing?: Record<string, number>\n defaultColumnSizing?: Record<string, number>\n onColumnSizingChange?: (sizing: Record<string, number>) => void\n children?: React.ReactNode\n}) {\n const containerRef = React.useRef<HTMLDivElement>(null)\n const rows = table.getRowModel().rows\n const visibleColumns = table.getAllColumns().filter((c) => c.getIsVisible())\n\n /* Column sizing (controllable) */\n const [initialSizing] = React.useState<Record<string, number>>(() => defaultColumnSizing ?? {})\n const [sizing, setSizing] = useControllable(sizingProp, initialSizing, onColumnSizingChange)\n const setWidth = React.useCallback(\n (columnId: string, width: number) => setSizing((prev) => ({ ...prev, [columnId]: width })),\n [setSizing]\n )\n\n const configById = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns])\n\n /* Layout: order = visible engine columns; pinned left first, right last */\n const { layout, layoutById, templateColumns, totalWidth } = React.useMemo(() => {\n const base = visibleColumns.map((c) => {\n const config = configById.get(c.id) ?? { id: c.id }\n const width = Math.max(config.minWidth ?? MIN_WIDTH, sizing[c.id] ?? config.width ?? DEFAULT_WIDTH)\n return { id: c.id, width, pinned: config.pinned, offset: 0, config }\n })\n const left = base.filter((c) => c.pinned === \"left\")\n const middle = base.filter((c) => !c.pinned)\n const right = base.filter((c) => c.pinned === \"right\")\n let offset = 0\n for (const c of left) {\n c.offset = offset\n offset += c.width\n }\n offset = 0\n for (const c of [...right].reverse()) {\n c.offset = offset\n offset += c.width\n }\n const ordered: ColumnLayout[] = [...left, ...middle, ...right]\n return {\n layout: ordered,\n layoutById: new Map(ordered.map((c) => [c.id, c])),\n templateColumns: ordered.map((c) => `${c.width}px`).join(\" \"),\n totalWidth: ordered.reduce((sum, c) => sum + c.width, 0),\n }\n // visibleColumns is derived from table state; its identity changes with it.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visibleColumns.map((c) => c.id).join(\"|\"), configById, sizing])\n\n /* Virtual window */\n const [scrollTop, setScrollTopState] = React.useState(0)\n const [viewportHeight, setViewportHeight] = React.useState(0)\n const setScrollTop = React.useCallback((top: number) => setScrollTopState(top), [])\n const window = React.useMemo(() => {\n const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)\n const end = Math.min(rows.length, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan)\n return { start, end }\n }, [scrollTop, viewportHeight, rowHeight, overscan, rows.length])\n\n /* Focus, range, editing */\n const [focus, setFocus] = React.useState<CellAddress | null>(null)\n const [range, setRange] = React.useState<CellRange | null>(null)\n const [editing, setEditing] = React.useState<(CellAddress & { seed?: string }) | null>(null)\n const { message: announcement, announce } = useLiveRegion()\n const [focusRequest, setFocusRequest] = React.useState(0)\n const activeEditor = React.useRef<{ close: () => void } | null>(null)\n const registerEditor = React.useCallback((handle: { close: () => void }) => {\n activeEditor.current = handle\n return () => {\n if (activeEditor.current === handle) activeEditor.current = null\n }\n }, [])\n const closeEditor = React.useCallback(() => activeEditor.current?.close(), [])\n\n const latest = React.useRef({ rows, layout, focus, range, editing, onCellChange, rowHeight })\n React.useEffect(() => {\n latest.current = { rows, layout, focus, range, editing, onCellChange, rowHeight }\n })\n\n const scrollRowIntoView = React.useCallback(\n (rowIndex: number) => {\n const el = containerRef.current\n if (!el) return\n const headerHeight = el.querySelector<HTMLElement>(\"[data-slot=data-grid-header]\")?.offsetHeight ?? 0\n const top = rowIndex * rowHeight\n const bottom = top + rowHeight\n const viewTop = el.scrollTop\n const viewBottom = el.scrollTop + el.clientHeight - headerHeight\n if (top < viewTop) el.scrollTop = top\n else if (bottom > viewBottom) el.scrollTop = bottom - (el.clientHeight - headerHeight)\n },\n [rowHeight]\n )\n\n const scrollColumnIntoView = React.useCallback((columnId: string) => {\n const el = containerRef.current\n const col = latest.current.layout.find((c) => c.id === columnId)\n if (!el || !col || col.pinned) return\n const left = latest.current.layout.slice(0, latest.current.layout.indexOf(col)).reduce((s, c) => s + c.width, 0)\n const pinnedLeft = latest.current.layout.filter((c) => c.pinned === \"left\").reduce((s, c) => s + c.width, 0)\n const pinnedRight = latest.current.layout.filter((c) => c.pinned === \"right\").reduce((s, c) => s + c.width, 0)\n if (left < el.scrollLeft + pinnedLeft) el.scrollLeft = left - pinnedLeft\n else if (left + col.width > el.scrollLeft + el.clientWidth - pinnedRight)\n el.scrollLeft = left + col.width - el.clientWidth + pinnedRight\n }, [])\n\n const focusCell = React.useCallback(\n (address: CellAddress, options?: { extend?: boolean; scroll?: boolean }) => {\n const { rows, layout } = latest.current\n if (!rows.length || !layout.length) return\n const rowIndex = clamp(address.rowIndex, 0, rows.length - 1)\n const columnId = layout.some((c) => c.id === address.columnId) ? address.columnId : layout[0].id\n const next = { rowIndex, columnId }\n setFocus(next)\n setRange((prev) =>\n options?.extend && (prev?.anchor ?? latest.current.focus)\n ? { anchor: prev?.anchor ?? latest.current.focus!, focus: next }\n : null\n )\n setFocusRequest((n) => n + 1)\n if (options?.scroll !== false) {\n scrollRowIntoView(rowIndex)\n scrollColumnIntoView(columnId)\n }\n },\n [scrollRowIntoView, scrollColumnIntoView]\n )\n\n const startEditing = React.useCallback((address: CellAddress, seed?: string) => {\n const config = latest.current.layout.find((c) => c.id === address.columnId)?.config\n if (!config?.editable) return\n setEditing({ ...address, seed })\n }, [])\n\n const stopEditing = React.useCallback((options?: { refocus?: boolean }) => {\n setEditing(null)\n if (options?.refocus !== false) setFocusRequest((n) => n + 1)\n }, [])\n\n const commit = React.useCallback((address: CellAddress, value: unknown) => {\n const row = latest.current.rows[address.rowIndex]\n if (!row) return\n const previous = row.getValue(address.columnId)\n if (Object.is(previous, value)) return\n latest.current.onCellChange?.({ row, columnId: address.columnId, value, previous })\n }, [])\n\n const rangeBounds = React.useCallback(\n () => boundsOf(latest.current.range, latest.current.focus, latest.current.layout),\n []\n )\n\n const isInRange = React.useCallback(\n (address: CellAddress) => {\n if (!range) return false\n const b = boundsOf(range, focus, layout)\n const ci = layout.findIndex((c) => c.id === address.columnId)\n return !!b && address.rowIndex >= b.r0 && address.rowIndex <= b.r1 && ci >= b.c0 && ci <= b.c1\n },\n [range, focus, layout]\n )\n\n const clearRange = React.useCallback(() => {\n const b = rangeBounds()\n if (!b) return\n const { rows, layout } = latest.current\n let count = 0\n for (let r = b.r0; r <= b.r1; r++) {\n for (let c = b.c0; c <= b.c1; c++) {\n const col = layout[c]\n if (!col?.config.editable) continue\n const row = rows[r]\n const empty = col.config.type === \"boolean\" ? false : col.config.type === \"number\" ? null : \"\"\n if (row && !Object.is(row.getValue(col.id), empty)) {\n latest.current.onCellChange?.({ row, columnId: col.id, value: empty, previous: row.getValue(col.id) })\n count++\n }\n }\n }\n if (count) announce(`Cleared ${count} cell${count === 1 ? \"\" : \"s\"}.`)\n }, [rangeBounds, announce])\n\n const copyRange = React.useCallback(async () => {\n const b = rangeBounds()\n if (!b) return\n const { rows, layout } = latest.current\n const lines: string[] = []\n for (let r = b.r0; r <= b.r1; r++) {\n const cells: string[] = []\n for (let c = b.c0; c <= b.c1; c++) {\n const col = layout[c]\n const value = rows[r]?.getValue(col.id)\n cells.push((col.config.format ?? defaultFormat)(value))\n }\n lines.push(cells.join(\"\\t\"))\n }\n try {\n await navigator.clipboard.writeText(lines.join(\"\\n\"))\n const n = (b.r1 - b.r0 + 1) * (b.c1 - b.c0 + 1)\n announce(`Copied ${n} cell${n === 1 ? \"\" : \"s\"}.`)\n } catch {\n announce(\"Could not copy to the clipboard.\")\n }\n }, [rangeBounds, announce])\n\n const paste = React.useCallback(async () => {\n const { focus, rows, layout } = latest.current\n if (!focus) return\n let text = \"\"\n try {\n text = await navigator.clipboard.readText()\n } catch {\n announce(\"Could not read the clipboard.\")\n return\n }\n const lines = text\n .replace(/\\r/g, \"\")\n .split(\"\\n\")\n .filter((l, i, arr) => l !== \"\" || i < arr.length - 1)\n const startCol = layout.findIndex((c) => c.id === focus.columnId)\n let count = 0\n lines.forEach((line, dr) => {\n const row = rows[focus.rowIndex + dr]\n if (!row) return\n line.split(\"\\t\").forEach((cell, dc) => {\n const col = layout[startCol + dc]\n if (!col?.config.editable) return\n const previous = row.getValue(col.id)\n const value = parseInput(col.config, cell, previous)\n if (!Object.is(previous, value)) {\n latest.current.onCellChange?.({ row, columnId: col.id, value, previous })\n count++\n }\n })\n })\n announce(`Pasted into ${count} cell${count === 1 ? \"\" : \"s\"}.`)\n }, [announce])\n\n const ctx = React.useMemo<DataGridContextValue<TData>>(\n () => ({\n table,\n rows,\n layout,\n layoutById,\n templateColumns,\n totalWidth,\n rowHeight,\n setWidth,\n focus,\n range,\n editing,\n focusCell,\n startEditing,\n stopEditing,\n commit,\n clearRange,\n copyRange,\n paste,\n isInRange,\n containerRef,\n focusRequest,\n registerEditor,\n closeEditor,\n window,\n setScrollTop,\n setViewportHeight,\n announce,\n }),\n [\n table,\n rows,\n layout,\n layoutById,\n templateColumns,\n totalWidth,\n rowHeight,\n setWidth,\n focus,\n range,\n editing,\n focusCell,\n startEditing,\n stopEditing,\n commit,\n clearRange,\n copyRange,\n paste,\n isInRange,\n focusRequest,\n registerEditor,\n closeEditor,\n window,\n setScrollTop,\n announce,\n ]\n )\n\n return (\n <DataGridContext.Provider value={ctx as DataGridContextValue}>\n <div\n data-slot=\"data-grid\"\n data-editing={editing ? \"\" : undefined}\n className={cn(\"flex min-h-0 flex-1 flex-col\", className)}\n {...props}\n >\n {children}\n <LiveRegion data-slot=\"data-grid-live-region\" message={announcement} />\n </div>\n </DataGridContext.Provider>\n )\n}\n\nfunction boundsOf(range: CellRange | null, focus: CellAddress | null, layout: ColumnLayout[]) {\n const a = range?.anchor ?? focus\n const b = range?.focus ?? focus\n if (!a || !b) return null\n const ca = layout.findIndex((c) => c.id === a.columnId)\n const cb = layout.findIndex((c) => c.id === b.columnId)\n return {\n r0: Math.min(a.rowIndex, b.rowIndex),\n r1: Math.max(a.rowIndex, b.rowIndex),\n c0: Math.min(ca, cb),\n c1: Math.max(ca, cb),\n }\n}\n\nfunction parseInput(config: DataGridColumnConfig, input: string, previous: unknown): unknown {\n if (config.parse) return config.parse(input, previous)\n switch (config.type) {\n case \"number\": {\n if (input.trim() === \"\") return null\n const n = Number(input)\n return Number.isNaN(n) ? previous : n\n }\n case \"boolean\":\n return /^(true|yes|1|on|x)$/i.test(input.trim())\n case \"date\": {\n if (!input) return null\n const d = new Date(input)\n return Number.isNaN(d.getTime()) ? previous : previous instanceof Date ? d : input\n }\n default:\n return input\n }\n}\n\n/* ---------------------------------------------------------------------------\n * Container, header, body\n * ------------------------------------------------------------------------- */\n\nfunction DataGridContainer({ className, onScroll, ...props }: React.ComponentProps<\"div\">) {\n const { containerRef, setScrollTop, setViewportHeight, rows, closeEditor } = useDataGrid()\n React.useLayoutEffect(() => {\n const el = containerRef.current\n if (!el) return\n const update = () => setViewportHeight(el.clientHeight)\n update()\n const observer = new ResizeObserver(update)\n observer.observe(el)\n return () => observer.disconnect()\n }, [containerRef, setViewportHeight])\n return (\n <div\n ref={containerRef}\n data-slot=\"data-grid-container\"\n role=\"grid\"\n aria-rowcount={rows.length}\n className={cn(\"relative min-h-0 flex-1 overflow-auto rounded-lg border bg-background text-sm\", className)}\n onScroll={(event) => {\n onScroll?.(event)\n // An open editor (and its portaled popover) must not drift away from its cell.\n closeEditor()\n setScrollTop(event.currentTarget.scrollTop)\n }}\n {...props}\n />\n )\n}\n\nfunction DataGridHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"data-grid-header\"\n role=\"rowgroup\"\n className={cn(\"sticky top-0 z-30 w-fit min-w-full bg-muted\", className)}\n {...props}\n />\n )\n}\n\nfunction DataGridHeaderRow({ className, style, ...props }: React.ComponentProps<\"div\">) {\n const { templateColumns, totalWidth } = useDataGrid()\n return (\n <div\n data-slot=\"data-grid-header-row\"\n role=\"row\"\n className={cn(\"grid border-b bg-muted\", className)}\n style={{ gridTemplateColumns: templateColumns, minWidth: totalWidth, ...style }}\n {...props}\n />\n )\n}\n\nconst pinnedStyle = (col: ColumnLayout | undefined): React.CSSProperties | undefined =>\n col?.pinned === \"left\" ? { left: col.offset } : col?.pinned === \"right\" ? { right: col.offset } : undefined\n\n/** A header cell. Sortable columns get a sort button with `children` as the title. */\nfunction DataGridHead({\n column: columnId,\n className,\n children,\n style,\n ...props\n}: React.ComponentProps<\"div\"> & { column: string }) {\n const { table, layoutById } = useDataGrid()\n const column = table.getColumn(columnId)\n const layout = layoutById.get(columnId)\n if (!column || !layout) return null\n const sorted = column.getIsSorted()\n const sortable = column.getCanSort()\n return (\n <div\n data-slot=\"data-grid-head\"\n data-column={columnId}\n data-pinned={layout.pinned}\n role=\"columnheader\"\n aria-sort={sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : undefined}\n className={cn(\n \"group/head relative flex h-9 items-center gap-1 overflow-hidden px-2 font-medium whitespace-nowrap text-muted-foreground\",\n layout.pinned && \"sticky z-10 bg-muted\",\n layout.pinned === \"left\" && \"border-e\",\n layout.pinned === \"right\" && \"border-s\",\n className\n )}\n style={{ ...pinnedStyle(layout), ...style }}\n {...props}\n >\n {sortable ? (\n <ark.button\n type=\"button\"\n data-slot=\"data-grid-sort-trigger\"\n className=\"-ms-1 flex h-7 min-w-0 items-center gap-1 rounded-md px-1 outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n onClick={() => column.toggleSorting()}\n >\n <span className=\"truncate\">{children}</span>\n {sorted === \"desc\" ? (\n <ArrowDownIcon className=\"size-3.5 shrink-0\" />\n ) : sorted === \"asc\" ? (\n <ArrowUpIcon className=\"size-3.5 shrink-0\" />\n ) : (\n <ChevronsUpDownIcon className=\"size-3.5 shrink-0 opacity-50\" />\n )}\n </ark.button>\n ) : (\n <span className=\"truncate\">{children}</span>\n )}\n </div>\n )\n}\n\n/** Pointer-driven column resizer; double-click resets to the configured width. */\nfunction DataGridResizeHandle({ className, onPointerDown, onDoubleClick, ...props }: React.ComponentProps<\"div\">) {\n const { setWidth, layoutById } = useDataGrid()\n const head = React.useContext(HeadColumnContext)\n return (\n <div\n data-slot=\"data-grid-resize-handle\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n className={cn(\n \"absolute inset-y-0 inset-e-0 z-10 w-2 cursor-col-resize touch-none select-none after:absolute after:inset-y-2 after:inset-e-0.5 after:w-px after:bg-border group-hover/head:after:bg-foreground/40 hover:after:bg-primary\",\n className\n )}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n if (event.defaultPrevented || event.button !== 0 || !head) return\n event.preventDefault()\n event.stopPropagation()\n const start = event.clientX\n const startWidth = layoutById.get(head)?.width ?? DEFAULT_WIDTH\n const min = layoutById.get(head)?.config.minWidth ?? MIN_WIDTH\n const move = (e: PointerEvent) => setWidth(head, Math.max(min, startWidth + e.clientX - start))\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 onDoubleClick={(event) => {\n onDoubleClick?.(event)\n if (!event.defaultPrevented && head) {\n const config = layoutById.get(head)?.config\n setWidth(head, config?.width ?? DEFAULT_WIDTH)\n }\n }}\n {...props}\n />\n )\n}\n\nconst HeadColumnContext = React.createContext<string | null>(null)\n\n/** Wraps `DataGridHead` so `DataGridResizeHandle` knows its column. */\nfunction DataGridHeadWithResize({ column, children, ...props }: React.ComponentProps<typeof DataGridHead>) {\n return (\n <HeadColumnContext.Provider value={column}>\n <DataGridHead column={column} {...props}>\n {children}\n <DataGridResizeHandle />\n </DataGridHead>\n </HeadColumnContext.Provider>\n )\n}\n\n/** Virtualized rows. `children` renders the cells for a row. */\nfunction DataGridBody<TData>({\n className,\n children,\n style,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n children: (row: DataTableRow<TData>, index: number) => React.ReactNode\n}) {\n const { rows, window: win, rowHeight, totalWidth } = useDataGrid<TData>()\n const visible = rows.slice(win.start, win.end)\n return (\n <div\n data-slot=\"data-grid-body\"\n role=\"rowgroup\"\n className={cn(\"relative\", className)}\n style={{ height: rows.length * rowHeight, minWidth: totalWidth, ...style }}\n {...props}\n >\n <div style={{ transform: `translateY(${win.start * rowHeight}px)` }}>\n {visible.map((row, i) => (\n <DataGridRow key={row.id} row={row} index={win.start + i}>\n {children(row, win.start + i)}\n </DataGridRow>\n ))}\n </div>\n </div>\n )\n}\n\nfunction DataGridRow<TData>({\n row,\n index,\n className,\n style,\n ...props\n}: React.ComponentProps<\"div\"> & { row: DataTableRow<TData>; index: number }) {\n const { templateColumns, rowHeight, focus } = useDataGrid()\n const ctx = React.useMemo(() => ({ row: row as DataTableRow<unknown>, index }), [row, index])\n const selected = row.getIsSelected()\n return (\n <RowContext.Provider value={ctx}>\n <div\n data-slot=\"data-grid-row\"\n data-index={index}\n data-state={selected ? \"selected\" : undefined}\n data-focused={focus?.rowIndex === index ? \"\" : undefined}\n role=\"row\"\n aria-rowindex={index + 1}\n aria-selected={selected || undefined}\n className={cn(\n \"group/row grid border-b transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted/60\",\n className\n )}\n style={{ gridTemplateColumns: templateColumns, height: rowHeight, ...style }}\n {...props}\n />\n </RowContext.Provider>\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Cells\n * ------------------------------------------------------------------------- */\n\nconst isPrintable = (event: React.KeyboardEvent) =>\n event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey\n\nfunction DataGridCell({\n column: columnId,\n editable: editableProp,\n editor,\n className,\n children,\n style,\n onClick,\n onDoubleClick,\n onKeyDown,\n onPointerDown,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n column: string\n /** Override the column config for this cell. */\n editable?: boolean\n /** Custom editor; return `undefined` to use the default for the column type. */\n editor?: (props: EditorRenderProps) => React.ReactNode | undefined\n children?: React.ReactNode\n}) {\n const grid = useDataGrid()\n const { row, index } = useDataGridRow()\n const layout = grid.layoutById.get(columnId)\n const address = React.useMemo(() => ({ rowIndex: index, columnId }), [index, columnId])\n const ref = React.useRef<HTMLDivElement>(null)\n const focused = grid.focus?.rowIndex === index && grid.focus.columnId === columnId\n const isEditing = grid.editing?.rowIndex === index && grid.editing.columnId === columnId\n // Take DOM focus when this cell is the focused one and a focus request is pending\n // (after navigation, after an editor closes, or once a virtualized row mounts).\n React.useLayoutEffect(() => {\n const el = ref.current\n if (!focused || isEditing || !el) return\n if (document.activeElement !== el && !el.contains(document.activeElement)) el.focus({ preventScroll: true })\n }, [focused, isEditing, grid.focusRequest])\n if (!layout) return null\n const config = layout.config\n const editable = editableProp ?? !!config.editable\n const inRange = grid.isInRange(address)\n const value = row.getValue(columnId)\n const format = config.format ?? defaultFormat\n const columnIndex = grid.layout.indexOf(layout)\n\n const move = (dr: number, dc: number, extend = false) => {\n // Navigate from the focused address in state; DOM focus can lag a frame after an edit.\n const base = grid.focus ?? address\n const baseCol = Math.max(\n 0,\n grid.layout.findIndex((c) => c.id === base.columnId)\n )\n const nextCol = grid.layout[clamp(baseCol + dc, 0, grid.layout.length - 1)]\n grid.focusCell({ rowIndex: base.rowIndex + dr, columnId: nextCol.id }, { extend })\n }\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(event)\n if (event.defaultPrevented || isEditing) return\n const meta = event.metaKey || event.ctrlKey\n const pageRows = Math.max(1, Math.floor((grid.containerRef.current?.clientHeight ?? 0) / grid.rowHeight) - 1)\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault()\n move(1, 0, event.shiftKey)\n return\n case \"ArrowUp\":\n event.preventDefault()\n move(-1, 0, event.shiftKey)\n return\n case \"ArrowRight\":\n event.preventDefault()\n move(0, 1, event.shiftKey)\n return\n case \"ArrowLeft\":\n event.preventDefault()\n move(0, -1, event.shiftKey)\n return\n case \"Home\":\n event.preventDefault()\n grid.focusCell({ rowIndex: meta ? 0 : index, columnId: grid.layout[0].id }, { extend: event.shiftKey })\n return\n case \"End\":\n event.preventDefault()\n grid.focusCell(\n { rowIndex: meta ? grid.rows.length - 1 : index, columnId: grid.layout[grid.layout.length - 1].id },\n { extend: event.shiftKey }\n )\n return\n case \"PageDown\":\n event.preventDefault()\n move(pageRows, 0, event.shiftKey)\n return\n case \"PageUp\":\n event.preventDefault()\n move(-pageRows, 0, event.shiftKey)\n return\n case \"Tab\":\n event.preventDefault()\n move(0, event.shiftKey ? -1 : 1)\n return\n case \"Enter\":\n case \"F2\":\n event.preventDefault()\n if (editable) grid.startEditing(address)\n else if (event.key === \"Enter\") move(1, 0)\n return\n case \" \":\n if (editable && config.type === \"boolean\") {\n event.preventDefault()\n grid.commit(address, !value)\n }\n return\n case \"Escape\":\n grid.focusCell(address)\n return\n case \"Delete\":\n case \"Backspace\":\n event.preventDefault()\n grid.clearRange()\n return\n case \"c\":\n case \"C\":\n if (meta) {\n event.preventDefault()\n void grid.copyRange()\n }\n return\n case \"v\":\n case \"V\":\n if (meta) {\n event.preventDefault()\n void grid.paste()\n }\n return\n }\n if (editable && isPrintable(event) && config.type !== \"boolean\") {\n event.preventDefault()\n grid.startEditing(address, event.key)\n }\n }\n\n return (\n <div\n ref={ref}\n data-slot=\"data-grid-cell\"\n data-column={columnId}\n data-row-index={index}\n data-type={config.type}\n data-pinned={layout.pinned}\n data-editable={editable ? \"\" : undefined}\n data-focused={focused ? \"\" : undefined}\n data-editing={isEditing ? \"\" : undefined}\n data-selected={inRange ? \"\" : undefined}\n role=\"gridcell\"\n aria-colindex={columnIndex + 1}\n aria-readonly={editable ? undefined : true}\n tabIndex={focused ? 0 : -1}\n className={cn(\n \"relative flex min-w-0 items-center overflow-hidden px-2 whitespace-nowrap outline-none\",\n \"data-selected:bg-primary/10\",\n \"data-focused:z-10 data-focused:ring-2 data-focused:ring-primary data-focused:ring-inset\",\n \"data-editing:bg-background data-editing:p-0\",\n layout.pinned && \"sticky z-20 bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted\",\n layout.pinned === \"left\" && \"border-e\",\n layout.pinned === \"right\" && \"border-s\",\n config.type === \"number\" && \"justify-end tabular-nums\",\n className\n )}\n style={{ ...pinnedStyle(layout), ...style }}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n if (event.defaultPrevented || event.button !== 0 || isEditing) return\n if ((event.target as HTMLElement).closest(\"button, a, input, select, textarea, [role=checkbox]\")) return\n grid.focusCell(address, { extend: event.shiftKey, scroll: false })\n }}\n onClick={onClick}\n onDoubleClick={(event) => {\n onDoubleClick?.(event)\n if (!event.defaultPrevented && editable && config.type !== \"boolean\") grid.startEditing(address)\n }}\n onKeyDown={handleKeyDown}\n {...props}\n >\n {isEditing ? (\n <CellEditor\n address={address}\n config={config}\n row={row}\n value={value}\n format={format}\n editor={editor}\n display={children}\n />\n ) : children !== undefined ? (\n children\n ) : config.type === \"boolean\" ? (\n <Checkbox\n checked={!!value}\n disabled={!editable}\n onCheckedChange={({ checked }) => grid.commit(address, checked === true)}\n aria-label={columnId}\n />\n ) : (\n <span className=\"truncate\">{format(value)}</span>\n )}\n </div>\n )\n}\n\nfunction CellEditor({\n address,\n config,\n row,\n value,\n format,\n editor,\n display,\n}: {\n address: CellAddress\n config: DataGridColumnConfig\n row: DataTableRow<unknown>\n value: unknown\n format: (value: unknown) => string\n editor?: (props: EditorRenderProps) => React.ReactNode | undefined\n display?: React.ReactNode\n}) {\n const grid = useDataGrid()\n const seed = grid.editing?.seed\n const [text, setText] = React.useState(() => seed ?? format(value))\n const inputRef = React.useRef<HTMLInputElement>(null)\n const done = React.useRef(false)\n React.useLayoutEffect(() => {\n const el = inputRef.current\n if (!el) return\n el.focus()\n if (seed === undefined && \"select\" in el && typeof el.select === \"function\" && el.type !== \"date\") el.select()\n }, [seed])\n\n const commit = (next?: string, then?: () => void) => {\n if (done.current) return\n done.current = true\n grid.commit(address, parseInput(config, next ?? text, value))\n grid.stopEditing({ refocus: !then })\n then?.()\n }\n const cancel = () => {\n if (done.current) return\n done.current = true\n grid.stopEditing()\n }\n const isSelect = config.type === \"select\" && !!config.options\n const commitRef = React.useRef(commit)\n const cancelRef = React.useRef(cancel)\n React.useEffect(() => {\n commitRef.current = commit\n cancelRef.current = cancel\n })\n const { registerEditor } = grid\n // Scrolling the grid closes the editor (text commits, an unpicked select cancels) before its\n // row can leave the virtual window, so the editor never unmounts mid-edit.\n React.useEffect(\n () => registerEditor({ close: () => (isSelect ? cancelRef.current() : commitRef.current()) }),\n [registerEditor, isSelect]\n )\n const moveAfter = (dr: number, dc: number) => () => {\n const col = grid.layout.findIndex((c) => c.id === address.columnId)\n const next = grid.layout[clamp(col + dc, 0, grid.layout.length - 1)]\n grid.focusCell({ rowIndex: address.rowIndex + dr, columnId: next.id })\n }\n const onKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\") {\n event.preventDefault()\n commit(undefined, moveAfter(1, 0))\n } else if (event.key === \"Tab\") {\n event.preventDefault()\n commit(undefined, moveAfter(0, event.shiftKey ? -1 : 1))\n } else if (event.key === \"Escape\") {\n event.preventDefault()\n cancel()\n }\n event.stopPropagation()\n }\n const inputProps: EditorRenderProps[\"inputProps\"] = {\n ref: inputRef,\n value: text,\n onChange: (e) => setText(e.target.value),\n onKeyDown,\n onBlur: () => commit(),\n className: \"size-full bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground\",\n \"aria-label\": \"Edit value\",\n }\n const custom = editor?.({ value: text, onChange: setText, commit, cancel, column: config, row, inputProps })\n if (custom !== undefined) return <>{custom}</>\n\n if (config.type === \"select\" && config.options) {\n return (\n <SelectEditor\n options={config.options}\n value={text}\n display={display}\n onCommit={(v) => commit(v, moveAfter(1, 0))}\n onCancel={cancel}\n />\n )\n }\n const type = config.type === \"number\" ? \"number\" : config.type === \"date\" ? \"date\" : \"text\"\n return <input data-slot=\"data-grid-editor\" type={type} {...inputProps} />\n}\n\n/** Opens the toolkit Select immediately; picking commits, closing without a pick cancels. */\nfunction SelectEditor({\n options,\n value,\n display,\n onCommit,\n onCancel,\n}: {\n options: DataGridOption[]\n value: string\n /** What the cell normally shows, so the open editor looks like the cell. */\n display?: React.ReactNode\n onCommit: (value: string) => void\n onCancel: () => void\n}) {\n const collection = React.useMemo(\n () => createListCollection({ items: options, itemToValue: (o) => o.value, itemToString: (o) => o.label }),\n [options]\n )\n const picked = React.useRef(false)\n const contentId = React.useId()\n // Ark handles list navigation on the open content, so focus it (it is portaled) once mounted.\n React.useLayoutEffect(() => {\n const focusContent = () => document.getElementById(contentId)?.focus({ preventScroll: true })\n focusContent()\n const timer = setTimeout(focusContent, 0)\n return () => clearTimeout(timer)\n }, [contentId])\n const label = options.find((o) => o.value === value)?.label ?? value\n return (\n <Select\n collection={collection}\n value={value ? [value] : []}\n defaultOpen\n ids={{ content: contentId }}\n positioning={{ sameWidth: true, gutter: 2 }}\n onValueChange={({ value: next }) => {\n if (next[0] === undefined) return\n picked.current = true\n onCommit(next[0])\n }}\n onOpenChange={({ open }) => {\n if (!open && !picked.current) onCancel()\n }}\n onPointerDown={(event) => event.stopPropagation()}\n className=\"size-full\"\n >\n <SelectControl className=\"size-full\">\n <SelectTrigger\n variant=\"unstyled\"\n data-slot=\"data-grid-select-editor\"\n className=\"size-full px-2 [&_svg]:text-muted-foreground\"\n aria-label=\"Edit value\"\n >\n <SelectValue placeholder=\"Choose…\">{display ?? label}</SelectValue>\n </SelectTrigger>\n </SelectControl>\n <SelectContent>\n {options.map((o) => (\n <SelectItem key={o.value} item={o}>\n <SelectItemText>{o.label}</SelectItemText>\n <SelectItemIndicator />\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Selection cells, empty\n * ------------------------------------------------------------------------- */\n\nfunction DataGridSelectAll({ className, ...props }: React.ComponentProps<\"div\">) {\n const { table, layoutById } = useDataGrid()\n const layout = layoutById.get(\"select\")\n const t = table as DataTableInstance<unknown> & {\n getIsAllPageRowsSelected?: () => boolean\n getIsSomePageRowsSelected?: () => boolean\n toggleAllPageRowsSelected?: (v?: boolean) => void\n }\n const all = t.getIsAllPageRowsSelected?.() ?? false\n const some = t.getIsSomePageRowsSelected?.() ?? false\n return (\n <div\n data-slot=\"data-grid-select-all\"\n role=\"columnheader\"\n className={cn(\n \"flex items-center justify-center\",\n layout?.pinned && \"sticky z-10 bg-muted\",\n layout?.pinned === \"left\" && \"border-e\",\n className\n )}\n style={pinnedStyle(layout)}\n {...props}\n >\n <Checkbox\n checked={all ? true : some ? \"indeterminate\" : false}\n onCheckedChange={({ checked }) => t.toggleAllPageRowsSelected?.(checked === true)}\n aria-label=\"Select all\"\n />\n </div>\n )\n}\n\nfunction DataGridSelectRow({ className, ...props }: React.ComponentProps<\"div\">) {\n const { layoutById } = useDataGrid()\n const { row } = useDataGridRow()\n const layout = layoutById.get(\"select\")\n return (\n <div\n data-slot=\"data-grid-select-row\"\n role=\"gridcell\"\n className={cn(\n \"flex items-center justify-center\",\n layout?.pinned && \"sticky z-20 bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted\",\n layout?.pinned === \"left\" && \"border-e\",\n className\n )}\n style={pinnedStyle(layout)}\n {...props}\n >\n <Checkbox\n checked={row.getIsSelected()}\n onCheckedChange={({ checked }) => row.toggleSelected(checked === true)}\n aria-label=\"Select row\"\n />\n </div>\n )\n}\n\nfunction DataGridEmpty({ className, ...props }: React.ComponentProps<\"div\">) {\n const { rows } = useDataGrid()\n if (rows.length) return null\n return (\n <div\n data-slot=\"data-grid-empty\"\n className={cn(\"flex h-24 items-center justify-center text-sm text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\nexport {\n DataGrid,\n DataGridContainer,\n DataGridHeader,\n DataGridHeaderRow,\n DataGridHead,\n DataGridHeadWithResize,\n DataGridResizeHandle,\n DataGridBody,\n DataGridRow,\n DataGridCell,\n DataGridSelectAll,\n DataGridSelectRow,\n DataGridEmpty,\n useDataGrid,\n useDataGridRow,\n type DataGridColumnConfig,\n type DataGridColumnType,\n type DataGridCellChange,\n type DataGridOption,\n}\n"
21
+ }
22
+ ],
23
+ "docs": "## Data grid\n`src/components/ui/data-grid.tsx` is an editable, keyboard-driven grid on the same source-agnostic\n`DataTableInstance` contract as the data table (`useDataTable` with a huge `pageSize` feeds it every\nrow; `DataTable` toolbar parts can wrap it). Column behaviour is a separate data-only `columns` prop\nkeyed by column id: `{ id, type: text|number|boolean|select|date, editable, options, width, minWidth,\npinned: left|right, format, parse }`. Edits are intents: `onCellChange({ row, columnId, value,\nprevious })`; the consumer owns the data. Parts (div-based, `role=\"grid\"`): `DataGrid` (context:\nlayout, focus, range, editing, virtual window; `columnSizing` controllable) > `DataGridContainer`\n(scroll viewport) > `DataGridHeader` > `DataGridHeaderRow` > `DataGridHead column=` (sort button\nwhen sortable) or `DataGridHeadWithResize` (adds `DataGridResizeHandle`; double-click resets),\n`DataGridBody` (`children: (row, index) => cells`, renders only the visible window plus\n`overscan`, fixed `rowHeight`) > `DataGridRow` > `DataGridCell column=` (roving tabindex,\n`data-focused/selected/editing/pinned/editable`; `editable` and `editor` render-prop overrides;\ndefault editors by type, native `<select>` for select columns), `DataGridSelectAll` /\n`DataGridSelectRow` (Checkbox cells for the `select` column), `DataGridEmpty`. Editors never use\nnative form controls: select columns open the toolkit `Select` (with `SelectTrigger\nvariant=\"unstyled\"`, focus moved to the listbox content via the root `ids`), booleans render a\n`Checkbox`, text/number/date use a borderless input; the cell owns the single focus ring. Pinned columns\nare sticky with computed offsets. Keyboard: arrows move, Shift+arrows extend the range,\nHome/End and Ctrl+Home/End jump, PageUp/Down, Enter/F2/typing edits, Enter commits + moves down,\nTab commits + moves right, Escape cancels, Space toggles booleans, Delete clears the range, ⌘C/⌘V\ncopy/paste TSV; a live region announces clears/copies. `src/demo/data-grid/` is the reference\n(`/contacts`, \"Contacts\"): 500 users, pinned select + username, per-type editors, and tracked\nchanges with Undo / Discard / Save in a `FloatingToolbar`."
24
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "data-table-adapter",
3
+ "type": "lib",
4
+ "description": "The contract the data table parts render against. `<DataTable>` accepts any object that satisfies it, so the parts are independent of where the rows, sorting, filtering, and paging come from. The built-in engine in `@/lib/table` satisfies i",
5
+ "dependencies": {},
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "lib/data-table-adapter.ts",
10
+ "content": "/**\n * The contract the data table parts render against. `<DataTable>` accepts any\n * object that satisfies it, so the parts are independent of where the rows,\n * sorting, filtering, and paging come from.\n *\n * The built-in engine in `@/lib/table` satisfies it directly and is the\n * default adapter. To drive the parts from another source (a server-side\n * query, a different table library, a store), implement this interface over\n * that source and pass the result to `<DataTable table={...}>`.\n *\n * Columns are data only: the adapter exposes ids, labels, sorting, visibility,\n * and filtering. Headers and cells are composed in JSX on the page with\n * `DataTableHead` / `DataTableCell`, keyed by column id.\n */\n\nexport type DataTableSortDirection = false | \"asc\" | \"desc\"\n\nexport interface DataTableColumn<TData, TValue = unknown> {\n id: string\n /** Human label for view options and headers; falls back to `id`. */\n label?: string\n /** Present on columns that read a value from the row (used by view options). */\n accessorFn?: ((row: TData, index: number) => TValue) | undefined\n getCanSort: () => boolean\n getIsSorted: () => DataTableSortDirection\n toggleSorting: (desc?: boolean) => void\n getCanHide: () => boolean\n getIsVisible: () => boolean\n toggleVisibility: (visible?: boolean) => void\n getFilterValue: () => unknown\n setFilterValue: (value: unknown) => void\n getFacetedUniqueValues: () => Map<unknown, number>\n}\n\nexport interface DataTableRow<TData> {\n id: string\n original: TData\n getValue: <TValue = unknown>(columnId: string) => TValue\n getIsSelected: () => boolean\n toggleSelected: (selected?: boolean) => void\n}\n\nexport interface DataTableInstance<TData> {\n getState: () => {\n globalFilter: string\n columnFilters: { id: string; value: unknown }[]\n pagination: { pageIndex: number; pageSize: number }\n }\n getAllColumns: () => DataTableColumn<TData>[]\n getColumn: (id: string) => DataTableColumn<TData> | undefined\n /** The rows to render (already filtered, sorted, and paginated). */\n getRowModel: () => { rows: DataTableRow<TData>[] }\n getFilteredSelectedRowModel: () => { rows: DataTableRow<TData>[] }\n setGlobalFilter: (value: string) => void\n resetColumnFilters: () => void\n setPageIndex: (index: number) => void\n setPageSize: (size: number) => void\n getPageCount: () => number\n getCanPreviousPage: () => boolean\n getCanNextPage: () => boolean\n previousPage: () => void\n nextPage: () => void\n resetRowSelection: () => void\n getIsAllPageRowsSelected: () => boolean\n getIsSomePageRowsSelected: () => boolean\n toggleAllPageRowsSelected: (selected?: boolean) => void\n}\n"
11
+ }
12
+ ],
13
+ "docs": "## Data table\n`src/lib/table.ts` is a dependency-free headless table engine (columns, sorting, global +\ncolumn filters with faceted counts, pagination, row selection, visibility). Column config is\ndata only: `{ id | accessorKey | accessorFn, label, enableSorting, enableHiding, filterFn,\nsortingFn }`. No render functions and no markup in column definitions.\n`src/components/ui/data-table.tsx` builds compositional parts on the source-agnostic\n`DataTableInstance` contract in `src/lib/data-table-adapter.ts`: toolbar, search, faceted\nfilter, reset, view options, `DataTableTable` > `DataTableHeader` (one `DataTableHead\ncolumn=\"id\"` per column; sortable columns get the sort/hide menu with `children` as the title)\n+ `DataTableBody` (`children` is `(row) => cells`; wrap in `DataTableRow` or return a fragment\nof `DataTableCell column=\"id\"` cells), `DataTableEmpty`, select-all/select-row cells, row\nactions, pagination pieces, bulk actions. Heads and cells keyed by a hidden column render\nnothing, so visibility toggles need no extra wiring. The toolkit must not know about specific\ntable libraries (no TanStack). `src/demo/tasks/` and `src/demo/users/` are the reference\nconsumers: `columns.tsx` is pure config, `*-table.tsx` composes the markup."
14
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "data-table",
3
+ "type": "ui",
4
+ "description": "`src/lib/table.ts` is a dependency-free headless table engine (columns, sorting, global + column filters with faceted counts, pagination, row selection, visibility).",
5
+ "dependencies": {
6
+ "lucide-react": "^1.41.0"
7
+ },
8
+ "registryDependencies": [
9
+ "badge",
10
+ "button",
11
+ "checkbox",
12
+ "command",
13
+ "data-table-adapter",
14
+ "dropdown-menu",
15
+ "floating-toolbar",
16
+ "input",
17
+ "live-region",
18
+ "popover",
19
+ "select",
20
+ "separator",
21
+ "table",
22
+ "tooltip",
23
+ "utils"
24
+ ],
25
+ "files": [
26
+ {
27
+ "path": "components/ui/data-table.tsx",
28
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n type ColumnDef,\n type ColumnFiltersState,\n type PaginationState,\n type Row,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type VisibilityState,\n useTable,\n} from \"@/lib/table\"\nimport {\n type DataTableColumn,\n type DataTableInstance,\n type DataTableRow as DataTableRowInstance,\n} from \"@/lib/data-table-adapter\"\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n CheckIcon,\n ChevronLeftIcon,\n ChevronRightIcon,\n ChevronsLeftIcon,\n ChevronsRightIcon,\n ChevronsUpDownIcon,\n EllipsisIcon,\n EyeOffIcon,\n PlusCircleIcon,\n Settings2Icon,\n XIcon,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { LiveRegion, useLiveRegion } from \"@/components/ui/live-region\"\n\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n useFilter,\n useListCollection,\n} from \"@/components/ui/command\"\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { Input } from \"@/components/ui/input\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\"\nimport {\n Select,\n SelectContent,\n SelectControl,\n SelectItem,\n SelectTrigger,\n SelectValue,\n createListCollection,\n} from \"@/components/ui/select\"\nimport { FloatingToolbar } from \"@/components/ui/floating-toolbar\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from \"@/components/ui/table\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\"\n\n/* -------------------------------------------------------------------------- */\n/* Hook */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The built-in adapter: the dependency-free engine from `@/lib/table`. Its\n * instance satisfies `DataTableInstance`, so it can be passed to `<DataTable>`\n * directly. Any other object implementing `DataTableInstance` works the same way.\n */\nfunction useDataTable<TData>(options: TableOptions<TData>) {\n return useTable<TData>(options)\n}\n\n/* -------------------------------------------------------------------------- */\n/* Context */\n/* -------------------------------------------------------------------------- */\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst DataTableContext = React.createContext<DataTableInstance<any> | null>(null)\n\nfunction useDataTableContext<TData>(explicit?: DataTableInstance<TData>): DataTableInstance<TData> {\n const fromContext = React.useContext(DataTableContext)\n const table = explicit ?? fromContext\n if (!table) {\n throw new Error(\"Data table parts must be rendered inside <DataTable> or receive a `table` prop.\")\n }\n return table\n}\n\nfunction DataTable<TData>({\n table,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { table: DataTableInstance<TData> }) {\n return (\n <DataTableContext.Provider value={table}>\n <div data-slot=\"data-table\" className={cn(\"flex flex-1 flex-col gap-4\", className)} {...props} />\n </DataTableContext.Provider>\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Toolbar */\n/* -------------------------------------------------------------------------- */\n\nfunction DataTableToolbar({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"data-table-toolbar\"\n className={cn(\"flex items-center justify-between gap-2\", className)}\n {...props}\n />\n )\n}\n\nfunction DataTableToolbarGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"data-table-toolbar-group\"\n className={cn(\"flex flex-1 flex-col-reverse items-start gap-2 sm:flex-row sm:items-center\", className)}\n {...props}\n />\n )\n}\n\nfunction DataTableSearch<TData>({\n table: tableProp,\n columnId,\n className,\n ...props\n}: Omit<React.ComponentProps<typeof Input>, \"value\" | \"onChange\"> & {\n table?: DataTableInstance<TData>\n /** Filter a single column instead of the global filter. */\n columnId?: string\n}) {\n const table = useDataTableContext(tableProp)\n const column = columnId ? table.getColumn(columnId) : undefined\n const value = columnId\n ? ((column?.getFilterValue() as string) ?? \"\")\n : ((table.getState().globalFilter as string) ?? \"\")\n return (\n <Input\n data-slot=\"data-table-search\"\n value={value}\n onChange={(event) =>\n columnId ? column?.setFilterValue(event.target.value) : table.setGlobalFilter(event.target.value)\n }\n className={cn(\"h-8 w-37.5 lg:w-62.5\", className)}\n {...props}\n />\n )\n}\n\nfunction DataTableResetFilters<TData>({\n table: tableProp,\n className,\n children,\n asChild,\n ...props\n}: React.ComponentProps<typeof Button> & { table?: DataTableInstance<TData> }) {\n const table = useDataTableContext(tableProp)\n const isFiltered = table.getState().columnFilters.length > 0 || !!table.getState().globalFilter\n if (!isFiltered) return null\n return (\n <Button\n data-slot=\"data-table-reset-filters\"\n variant=\"ghost\"\n size=\"sm\"\n asChild={asChild}\n onClick={() => {\n table.resetColumnFilters()\n table.setGlobalFilter(\"\")\n }}\n className={cn(\"h-8 px-2 lg:px-3\", className)}\n {...props}\n >\n {asChild\n ? children\n : (children ?? (\n <>\n Reset\n <XIcon />\n </>\n ))}\n </Button>\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Faceted filter */\n/* -------------------------------------------------------------------------- */\n\ntype FacetOption = {\n label: string\n value: string\n icon?: React.ComponentType<{ className?: string }>\n}\n\nfunction DataTableFacetedFilter<TData, TValue>({\n table: tableProp,\n columnId,\n column: columnProp,\n title,\n options,\n}: {\n table?: DataTableInstance<TData>\n columnId?: string\n column?: DataTableColumn<TData, TValue>\n title?: string\n options: FacetOption[]\n}) {\n const table = useDataTableContext(tableProp)\n const column = columnProp ?? (columnId ? table.getColumn(columnId) : undefined)\n const facets = column?.getFacetedUniqueValues()\n const selectedValues = new Set((column?.getFilterValue() as string[]) ?? [])\n\n const { contains } = useFilter({ sensitivity: \"base\" })\n const { collection, filter } = useListCollection<FacetOption>({\n initialItems: options,\n itemToString: (item) => item.label,\n itemToValue: (item) => item.value,\n filter: contains,\n })\n\n const toggle = (value: string) => {\n const next = new Set(selectedValues)\n if (next.has(value)) next.delete(value)\n else next.add(value)\n const values = Array.from(next)\n column?.setFilterValue(values.length ? values : undefined)\n }\n\n return (\n <Popover positioning={{ placement: \"bottom-start\" }}>\n <PopoverTrigger asChild>\n <Button data-slot=\"data-table-faceted-filter-trigger\" variant=\"outline\" size=\"sm\" className=\"h-8 border-dashed\">\n <PlusCircleIcon />\n {title}\n {selectedValues.size > 0 && (\n <>\n <Separator orientation=\"vertical\" className=\"mx-1 h-4\" />\n <Badge variant=\"secondary\" className=\"rounded-sm px-1 font-normal lg:hidden\">\n {selectedValues.size}\n </Badge>\n <div className=\"hidden gap-1 lg:flex\">\n {selectedValues.size > 2 ? (\n <Badge variant=\"secondary\" className=\"rounded-sm px-1 font-normal\">\n {selectedValues.size} selected\n </Badge>\n ) : (\n options\n .filter((option) => selectedValues.has(option.value))\n .map((option) => (\n <Badge key={option.value} variant=\"secondary\" className=\"rounded-sm px-1 font-normal\">\n {option.label}\n </Badge>\n ))\n )}\n </div>\n </>\n )}\n </Button>\n </PopoverTrigger>\n <PopoverContent data-slot=\"data-table-faceted-filter-content\" className=\"w-50 p-0\">\n <Command collection={collection} value={[]} onSelect={({ value }) => toggle(value)}>\n <CommandInput placeholder={title} onValueChange={filter} />\n <CommandList>\n <CommandEmpty>No results found.</CommandEmpty>\n <CommandGroup>\n {collection.items.map((option) => {\n const isSelected = selectedValues.has(option.value)\n return (\n <CommandItem\n key={option.value}\n item={option}\n className=\"**:data-[slot=command-item-indicator]:hidden\"\n >\n <div\n className={cn(\n \"flex size-4 items-center justify-center rounded-sm border border-primary\",\n isSelected ? \"bg-primary text-primary-foreground\" : \"opacity-50 [&_svg]:invisible\"\n )}\n >\n <CheckIcon className=\"size-3.5\" />\n </div>\n {option.icon && <option.icon className=\"size-4 text-muted-foreground\" />}\n <span>{option.label}</span>\n {facets?.get(option.value) ? (\n <span className=\"ml-auto flex size-4 items-center justify-center font-mono text-xs\">\n {facets.get(option.value)}\n </span>\n ) : null}\n </CommandItem>\n )\n })}\n </CommandGroup>\n {selectedValues.size > 0 && (\n <>\n <CommandSeparator />\n <CommandGroup>\n <CommandItem\n item={{ label: \"Clear filters\", value: \"__clear__\" }}\n onSelect={() => column?.setFilterValue(undefined)}\n className=\"justify-center text-center **:data-[slot=command-item-indicator]:hidden\"\n >\n Clear filters\n </CommandItem>\n </CommandGroup>\n </>\n )}\n </CommandList>\n </Command>\n </PopoverContent>\n </Popover>\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* View options */\n/* -------------------------------------------------------------------------- */\n\nfunction DataTableViewOptions<TData>({\n table: tableProp,\n className,\n children,\n}: {\n table?: DataTableInstance<TData>\n className?: string\n children?: React.ReactNode\n}) {\n const table = useDataTableContext(tableProp)\n return (\n <DropdownMenu positioning={{ placement: \"bottom-end\" }}>\n <DropdownMenuTrigger asChild>\n <Button\n data-slot=\"data-table-view-options-trigger\"\n variant=\"outline\"\n size=\"sm\"\n className={cn(\"ml-auto hidden h-8 lg:flex\", className)}\n >\n <Settings2Icon />\n {children ?? \"View\"}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent data-slot=\"data-table-view-options-content\" className=\"w-37.5\">\n <DropdownMenuGroup>\n <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>\n <DropdownMenuSeparator />\n {table\n .getAllColumns()\n .filter((column) => typeof column.accessorFn !== \"undefined\" && column.getCanHide())\n .map((column) => (\n <DropdownMenuCheckboxItem\n key={column.id}\n value={column.id}\n className=\"capitalize\"\n checked={column.getIsVisible()}\n onCheckedChange={(value) => column.toggleVisibility(!!value)}\n closeOnSelect={false}\n >\n {column.label ?? column.id}\n </DropdownMenuCheckboxItem>\n ))}\n </DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Column header */\n/* -------------------------------------------------------------------------- */\n\n/**\n * A header cell bound to a column id. Sortable columns get the sort/hide menu\n * with `children` as the title; hidden columns render nothing.\n */\nfunction DataTableHead<TData>({\n column: columnId,\n table: tableProp,\n className,\n children,\n ...props\n}: React.ComponentProps<typeof TableHead> & { column?: string; table?: DataTableInstance<TData> }) {\n const table = useDataTableContext(tableProp)\n const column = columnId ? table.getColumn(columnId) : undefined\n if (column && !column.getIsVisible()) return null\n if (!column?.getCanSort()) {\n return (\n <TableHead data-slot=\"data-table-head\" data-column={columnId} className={className} {...props}>\n {children}\n </TableHead>\n )\n }\n const sorted = column.getIsSorted()\n return (\n <TableHead data-slot=\"data-table-head\" data-column={columnId} className={className} {...props}>\n <DropdownMenu positioning={{ placement: \"bottom-start\" }}>\n <DropdownMenuTrigger asChild>\n <Button variant=\"ghost\" size=\"sm\" className=\"-ml-2.5 h-8 data-open:bg-accent\">\n <span>{children}</span>\n {sorted === \"desc\" ? <ArrowDownIcon /> : sorted === \"asc\" ? <ArrowUpIcon /> : <ChevronsUpDownIcon />}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem value=\"asc\" onSelect={() => column.toggleSorting(false)}>\n <ArrowUpIcon className=\"text-muted-foreground/70\" />\n Asc\n </DropdownMenuItem>\n <DropdownMenuItem value=\"desc\" onSelect={() => column.toggleSorting(true)}>\n <ArrowDownIcon className=\"text-muted-foreground/70\" />\n Desc\n </DropdownMenuItem>\n {column.getCanHide() && (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuItem value=\"hide\" onSelect={() => column.toggleVisibility(false)}>\n <EyeOffIcon className=\"text-muted-foreground/70\" />\n Hide\n </DropdownMenuItem>\n </>\n )}\n </DropdownMenuContent>\n </DropdownMenu>\n </TableHead>\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Selection cells */\n/* -------------------------------------------------------------------------- */\n\nfunction DataTableSelectAll<TData>({\n table,\n className,\n ...props\n}: Omit<React.ComponentProps<typeof Checkbox>, \"checked\" | \"onCheckedChange\"> & {\n table: DataTableInstance<TData>\n}) {\n return (\n <Checkbox\n data-slot=\"data-table-select-all\"\n checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && \"indeterminate\")}\n onCheckedChange={({ checked }) => table.toggleAllPageRowsSelected(checked === true)}\n aria-label=\"Select all\"\n className={cn(\"translate-y-0.5\", className)}\n {...props}\n />\n )\n}\n\nfunction DataTableSelectRow<TData>({\n row,\n className,\n ...props\n}: Omit<React.ComponentProps<typeof Checkbox>, \"checked\" | \"onCheckedChange\"> & {\n row: DataTableRowInstance<TData>\n}) {\n return (\n <Checkbox\n data-slot=\"data-table-select-row\"\n checked={row.getIsSelected()}\n onCheckedChange={({ checked }) => row.toggleSelected(checked === true)}\n aria-label=\"Select row\"\n className={cn(\"translate-y-0.5\", className)}\n {...props}\n />\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Row actions */\n/* -------------------------------------------------------------------------- */\n\nfunction DataTableRowActions({\n children,\n className,\n trigger,\n ...props\n}: React.ComponentProps<typeof DropdownMenu> & {\n className?: string\n /** Replace the default ellipsis button with your own element. */\n trigger?: React.ReactElement\n}) {\n return (\n <DropdownMenu positioning={{ placement: \"bottom-end\" }} {...props}>\n <DropdownMenuTrigger asChild>\n {trigger ?? (\n <Button\n data-slot=\"data-table-row-actions-trigger\"\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\"size-8 data-open:bg-muted\", className)}\n >\n <EllipsisIcon />\n <span className=\"sr-only\">Open menu</span>\n </Button>\n )}\n </DropdownMenuTrigger>\n <DropdownMenuContent data-slot=\"data-table-row-actions-content\" className=\"w-40\">\n {children}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Table rendering */\n/* -------------------------------------------------------------------------- */\n\nfunction DataTableContainer({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div data-slot=\"data-table-container\" className={cn(\"overflow-hidden rounded-md border\", className)} {...props} />\n )\n}\n\n/** The header row. Put `DataTableHead` cells inside, one per column, in display order. */\nfunction DataTableHeader({ className, children, ...props }: React.ComponentProps<typeof TableHeader>) {\n return (\n <TableHeader data-slot=\"data-table-header\" className={className} {...props}>\n <TableRow>{children}</TableRow>\n </TableHeader>\n )\n}\n\n/** A body row bound to a row instance; carries the selected state. */\nfunction DataTableRow<TData>({\n row,\n className,\n ...props\n}: React.ComponentProps<typeof TableRow> & { row: DataTableRowInstance<TData> }) {\n return (\n <TableRow\n data-slot=\"data-table-row\"\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n className={cn(\"group/row\", className)}\n {...props}\n />\n )\n}\n\n/** A body cell bound to a column id; hidden columns render nothing. */\nfunction DataTableCell<TData>({\n column: columnId,\n table: tableProp,\n className,\n ...props\n}: React.ComponentProps<typeof TableCell> & { column?: string; table?: DataTableInstance<TData> }) {\n const table = useDataTableContext(tableProp)\n const column = columnId ? table.getColumn(columnId) : undefined\n if (column && !column.getIsVisible()) return null\n return <TableCell data-slot=\"data-table-cell\" data-column={columnId} className={className} {...props} />\n}\n\n/**\n * Renders the current page of rows. `children` receives each row and returns\n * its cells (wrap them in `DataTableRow`, or return a fragment of cells to get\n * a default row). Shows `empty` when there are no rows.\n */\nfunction DataTableBody<TData>({\n table: tableProp,\n empty = \"No results.\",\n children,\n ...props\n}: Omit<React.ComponentProps<typeof TableBody>, \"children\"> & {\n table?: DataTableInstance<TData>\n empty?: React.ReactNode\n children: (row: DataTableRowInstance<TData>) => React.ReactNode\n}) {\n const table = useDataTableContext(tableProp)\n const rows = table.getRowModel().rows\n return (\n <TableBody data-slot=\"data-table-body\" {...props}>\n {rows.length ? (\n rows.map((row) => {\n const content = children(row)\n return React.isValidElement(content) && content.type === DataTableRow ? (\n <React.Fragment key={row.id}>{content}</React.Fragment>\n ) : (\n <DataTableRow key={row.id} row={row}>\n {content}\n </DataTableRow>\n )\n })\n ) : (\n <DataTableEmpty table={table}>{empty}</DataTableEmpty>\n )}\n </TableBody>\n )\n}\n\n/** A single full-width row for the empty state; spans the visible columns. */\nfunction DataTableEmpty<TData>({\n table: tableProp,\n colSpan,\n className,\n ...props\n}: React.ComponentProps<typeof TableCell> & { table?: DataTableInstance<TData>; colSpan?: number }) {\n const table = useDataTableContext(tableProp)\n const span = colSpan ?? Math.max(1, table.getAllColumns().filter((c) => c.getIsVisible()).length)\n return (\n <TableRow data-slot=\"data-table-empty\">\n <TableCell colSpan={span} className={cn(\"h-24 text-center\", className)} {...props} />\n </TableRow>\n )\n}\n\n/** The scrolling, bordered table. Compose `DataTableHeader` and `DataTableBody` inside. */\nfunction DataTableTable({ className, ...props }: React.ComponentProps<typeof Table>) {\n return (\n <DataTableContainer>\n <Table data-slot=\"data-table-table\" className={cn(\"min-w-xl\", className)} {...props} />\n </DataTableContainer>\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Pagination */\n/* -------------------------------------------------------------------------- */\n\nfunction getPageNumbers(currentPage: number, totalPages: number) {\n const maxVisiblePages = 5\n const range: Array<number | \"...\"> = []\n if (totalPages <= maxVisiblePages) {\n for (let i = 1; i <= totalPages; i++) range.push(i)\n } else {\n range.push(1)\n if (currentPage <= 3) {\n for (let i = 2; i <= 4; i++) range.push(i)\n range.push(\"...\", totalPages)\n } else if (currentPage >= totalPages - 2) {\n range.push(\"...\")\n for (let i = totalPages - 3; i <= totalPages; i++) range.push(i)\n } else {\n range.push(\"...\")\n for (let i = currentPage - 1; i <= currentPage + 1; i++) range.push(i)\n range.push(\"...\", totalPages)\n }\n }\n return range\n}\n\nfunction DataTablePagination({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"data-table-pagination\"\n className={cn(\"flex flex-col-reverse items-center justify-between gap-4 px-2 md:flex-row\", className)}\n {...props}\n />\n )\n}\n\nconst defaultPageSizes = [10, 20, 30, 40, 50]\n\nfunction DataTablePageSize<TData>({\n table: tableProp,\n pageSizes = defaultPageSizes,\n className,\n children,\n}: {\n table?: DataTableInstance<TData>\n pageSizes?: number[]\n className?: string\n children?: React.ReactNode\n}) {\n const table = useDataTableContext(tableProp)\n const collection = React.useMemo(() => createListCollection({ items: pageSizes.map(String) }), [pageSizes])\n const pageSize = table.getState().pagination.pageSize\n return (\n <div data-slot=\"data-table-page-size\" className={cn(\"flex items-center gap-2\", className)}>\n <Select\n collection={collection}\n value={[String(pageSize)]}\n onValueChange={({ value }) => table.setPageSize(Number(value[0]))}\n positioning={{ placement: \"top\", sameWidth: true }}\n >\n <SelectControl>\n <SelectTrigger className=\"h-8 w-17.5\">\n <SelectValue placeholder={String(pageSize)} />\n </SelectTrigger>\n </SelectControl>\n <SelectContent className=\"min-w-0\">\n {collection.items.map((size) => (\n <SelectItem key={size} item={size}>\n {size}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className=\"hidden text-sm font-medium sm:block\">{children ?? \"Rows per page\"}</p>\n </div>\n )\n}\n\nfunction DataTablePageInfo<TData>({\n table: tableProp,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { table?: DataTableInstance<TData> }) {\n const table = useDataTableContext(tableProp)\n const currentPage = table.getState().pagination.pageIndex + 1\n const totalPages = Math.max(table.getPageCount(), 1)\n return (\n <div\n data-slot=\"data-table-page-info\"\n className={cn(\"flex w-25 items-center justify-center text-sm font-medium\", className)}\n {...props}\n >\n Page {currentPage} of {totalPages}\n </div>\n )\n}\n\nfunction DataTablePageNav<TData>({\n table: tableProp,\n className,\n showEdges = true,\n ...props\n}: React.ComponentProps<\"div\"> & {\n table?: DataTableInstance<TData>\n /** Show the first and last page buttons. */\n showEdges?: boolean\n}) {\n const table = useDataTableContext(tableProp)\n const currentPage = table.getState().pagination.pageIndex + 1\n const totalPages = table.getPageCount()\n const pageNumbers = getPageNumbers(currentPage, totalPages)\n return (\n <div data-slot=\"data-table-page-nav\" className={cn(\"flex items-center gap-2\", className)} {...props}>\n {showEdges && (\n <Button\n variant=\"outline\"\n size=\"icon\"\n className=\"hidden size-8 md:inline-flex\"\n onClick={() => table.setPageIndex(0)}\n disabled={!table.getCanPreviousPage()}\n >\n <span className=\"sr-only\">Go to first page</span>\n <ChevronsLeftIcon />\n </Button>\n )}\n <Button\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8\"\n onClick={() => table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n >\n <span className=\"sr-only\">Go to previous page</span>\n <ChevronLeftIcon />\n </Button>\n {pageNumbers.map((pageNumber, index) =>\n pageNumber === \"...\" ? (\n <span key={`ellipsis-${index}`} className=\"px-1 text-sm text-muted-foreground\">\n ...\n </span>\n ) : (\n <Button\n key={pageNumber}\n variant={currentPage === pageNumber ? \"default\" : \"outline\"}\n size=\"sm\"\n className=\"h-8 min-w-8 px-2\"\n onClick={() => table.setPageIndex(pageNumber - 1)}\n aria-current={currentPage === pageNumber ? \"page\" : undefined}\n >\n <span className=\"sr-only\">Go to page </span>\n {pageNumber}\n </Button>\n )\n )}\n <Button\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8\"\n onClick={() => table.nextPage()}\n disabled={!table.getCanNextPage()}\n >\n <span className=\"sr-only\">Go to next page</span>\n <ChevronRightIcon />\n </Button>\n {showEdges && (\n <Button\n variant=\"outline\"\n size=\"icon\"\n className=\"hidden size-8 md:inline-flex\"\n onClick={() => table.setPageIndex(table.getPageCount() - 1)}\n disabled={!table.getCanNextPage()}\n >\n <span className=\"sr-only\">Go to last page</span>\n <ChevronsRightIcon />\n </Button>\n )}\n </div>\n )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Bulk actions */\n/* -------------------------------------------------------------------------- */\n\nfunction DataTableBulkActions<TData>({\n table: tableProp,\n entityName = \"row\",\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n table?: DataTableInstance<TData>\n entityName?: string\n}) {\n const table = useDataTableContext(tableProp)\n const selectedCount = table.getFilteredSelectedRowModel().rows.length\n const { message: announcement, announce } = useLiveRegion({ clearAfter: 3000 })\n const plural = selectedCount > 1 ? \"s\" : \"\"\n\n React.useEffect(() => {\n if (selectedCount === 0) return\n announce(`${selectedCount} ${entityName}${plural} selected. Bulk actions toolbar is available.`)\n }, [selectedCount, entityName, plural, announce])\n\n return (\n <>\n <LiveRegion data-slot=\"data-table-live-region\" message={announcement} />\n <FloatingToolbar\n open={selectedCount > 0}\n data-slot=\"data-table-bulk-actions\"\n aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${plural}`}\n onEscape={() => table.resetRowSelection()}\n className={className}\n {...props}\n >\n <DataTableBulkActionsClear table={table} />\n <Separator orientation=\"vertical\" className=\"h-5\" aria-hidden />\n <DataTableBulkActionsCount table={table} entityName={entityName} />\n <Separator orientation=\"vertical\" className=\"h-5\" aria-hidden />\n {children}\n </FloatingToolbar>\n </>\n )\n}\n\nfunction DataTableBulkActionsClear<TData>({\n table: tableProp,\n className,\n asChild,\n children,\n ...props\n}: React.ComponentProps<typeof Button> & { table?: DataTableInstance<TData> }) {\n const table = useDataTableContext(tableProp)\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n data-slot=\"data-table-bulk-actions-clear\"\n variant=\"outline\"\n size=\"icon\"\n asChild={asChild}\n onClick={() => table.resetRowSelection()}\n className={cn(\"size-6 rounded-full\", className)}\n aria-label=\"Clear selection\"\n {...props}\n >\n {asChild ? children : (children ?? <XIcon />)}\n </Button>\n </TooltipTrigger>\n <TooltipContent>Clear selection (Escape)</TooltipContent>\n </Tooltip>\n )\n}\n\nfunction DataTableBulkActionsCount<TData>({\n table: tableProp,\n entityName = \"row\",\n className,\n ...props\n}: React.ComponentProps<\"div\"> & {\n table?: DataTableInstance<TData>\n entityName?: string\n}) {\n const table = useDataTableContext(tableProp)\n const count = table.getFilteredSelectedRowModel().rows.length\n return (\n <div\n data-slot=\"data-table-bulk-actions-count\"\n className={cn(\"flex items-center gap-1 text-sm\", className)}\n {...props}\n >\n <Badge className=\"min-w-8 justify-center rounded-lg\" aria-label={`${count} selected`}>\n {count}\n </Badge>{\" \"}\n <span className=\"hidden sm:inline\">\n {entityName}\n {count > 1 ? \"s\" : \"\"}\n </span>{\" \"}\n selected\n </div>\n )\n}\n\nfunction DataTableBulkActionTrigger({\n label,\n className,\n children,\n asChild,\n ...props\n}: React.ComponentProps<typeof Button> & { label: string }) {\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n data-slot=\"data-table-bulk-action-trigger\"\n variant=\"outline\"\n size=\"icon\"\n asChild={asChild}\n className={cn(\"size-8\", className)}\n aria-label={label}\n {...props}\n >\n {asChild ? (\n children\n ) : (\n <>\n {children}\n <span className=\"sr-only\">{label}</span>\n </>\n )}\n </Button>\n </TooltipTrigger>\n <TooltipContent>{label}</TooltipContent>\n </Tooltip>\n )\n}\n\nexport {\n DataTable,\n DataTableBody,\n DataTableBulkActionTrigger,\n DataTableBulkActions,\n DataTableBulkActionsClear,\n DataTableBulkActionsCount,\n DataTableCell,\n DataTableHead,\n DataTableContainer,\n DataTableEmpty,\n DataTableFacetedFilter,\n DataTableHeader,\n DataTablePageInfo,\n DataTablePageNav,\n DataTablePageSize,\n DataTablePagination,\n DataTableResetFilters,\n DataTableRow,\n DataTableRowActions,\n DataTableSearch,\n DataTableSelectAll,\n DataTableSelectRow,\n DataTableTable,\n DataTableToolbar,\n DataTableToolbarGroup,\n DataTableViewOptions,\n getPageNumbers,\n useDataTable,\n useDataTableContext,\n type ColumnDef,\n type ColumnFiltersState,\n type FacetOption,\n type PaginationState,\n type Row,\n type RowSelectionState,\n type SortingState,\n type DataTableColumn,\n type DataTableInstance,\n type DataTableRowInstance,\n type TableOptions,\n type VisibilityState,\n}\n"
29
+ }
30
+ ],
31
+ "docs": "## Data table\n`src/lib/table.ts` is a dependency-free headless table engine (columns, sorting, global +\ncolumn filters with faceted counts, pagination, row selection, visibility). Column config is\ndata only: `{ id | accessorKey | accessorFn, label, enableSorting, enableHiding, filterFn,\nsortingFn }`. No render functions and no markup in column definitions.\n`src/components/ui/data-table.tsx` builds compositional parts on the source-agnostic\n`DataTableInstance` contract in `src/lib/data-table-adapter.ts`: toolbar, search, faceted\nfilter, reset, view options, `DataTableTable` > `DataTableHeader` (one `DataTableHead\ncolumn=\"id\"` per column; sortable columns get the sort/hide menu with `children` as the title)\n+ `DataTableBody` (`children` is `(row) => cells`; wrap in `DataTableRow` or return a fragment\nof `DataTableCell column=\"id\"` cells), `DataTableEmpty`, select-all/select-row cells, row\nactions, pagination pieces, bulk actions. Heads and cells keyed by a hidden column render\nnothing, so visibility toggles need no extra wiring. The toolkit must not know about specific\ntable libraries (no TanStack). `src/demo/tasks/` and `src/demo/users/` are the reference\nconsumers: `columns.tsx` is pure config, `*-table.tsx` composes the markup."
32
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "date-input",
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/date-input.tsx",
14
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { DateInput as DateInputPrimitive } from \"@ark-ui/react\"\n\nfunction DateInput({ className, children, ...props }: React.ComponentProps<typeof DateInputPrimitive.Root>) {\n return (\n <DateInputPrimitive.Root\n data-slot=\"date-input\"\n className={cn(\"flex w-full flex-col gap-1.5\", className)}\n {...props}\n >\n {children ?? (\n <DateInputControl>\n <DateInputSegmentGroup />\n <DateInputHiddenInput />\n </DateInputControl>\n )}\n </DateInputPrimitive.Root>\n )\n}\n\nfunction DateInputContext({ ...props }: React.ComponentProps<typeof DateInputPrimitive.Context>) {\n return <DateInputPrimitive.Context {...props} />\n}\n\nfunction DateInputLabel({ className, ...props }: React.ComponentProps<typeof DateInputPrimitive.Label>) {\n return (\n <DateInputPrimitive.Label\n data-slot=\"date-input-label\"\n className={cn(\"text-sm font-medium select-none\", className)}\n {...props}\n />\n )\n}\n\nfunction DateInputControl({ className, ...props }: React.ComponentProps<typeof DateInputPrimitive.Control>) {\n return (\n <DateInputPrimitive.Control\n data-slot=\"date-input-control\"\n className={cn(\n \"flex h-8 w-fit items-center rounded-lg border border-input bg-transparent px-2.5 text-sm transition-colors has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50 data-invalid:border-destructive data-invalid:ring-3 data-invalid:ring-destructive/20 dark:bg-input/30 data-disabled:pointer-events-none data-disabled:opacity-50\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction DateInputSegmentGroup({\n className,\n children,\n index = 0,\n ...props\n}: React.ComponentProps<typeof DateInputPrimitive.SegmentGroup>) {\n return (\n <DateInputPrimitive.SegmentGroup\n data-slot=\"date-input-segment-group\"\n index={index}\n className={cn(\"flex items-center\", className)}\n {...props}\n >\n {children ?? (\n <DateInputPrimitive.Context>\n {(api) => api.getSegments({ index }).map((segment, i) => <DateInputSegment key={i} segment={segment} />)}\n </DateInputPrimitive.Context>\n )}\n </DateInputPrimitive.SegmentGroup>\n )\n}\n\nfunction DateInputSegment({ className, ...props }: React.ComponentProps<typeof DateInputPrimitive.Segment>) {\n return (\n <DateInputPrimitive.Segment\n data-slot=\"date-input-segment\"\n className={cn(\n \"rounded-sm px-0.5 tabular-nums outline-none data-editable:focus:bg-primary data-editable:focus:text-primary-foreground data-placeholder-shown:text-muted-foreground data-[type=literal]:text-muted-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction DateInputSegmentContext({ ...props }: React.ComponentProps<typeof DateInputPrimitive.SegmentContext>) {\n return <DateInputPrimitive.SegmentContext {...props} />\n}\n\nfunction DateInputHiddenInput({ ...props }: React.ComponentProps<typeof DateInputPrimitive.HiddenInput>) {\n return <DateInputPrimitive.HiddenInput {...props} />\n}\n\nexport {\n DateInput,\n DateInputContext,\n DateInputControl,\n DateInputHiddenInput,\n DateInputLabel,\n DateInputSegment,\n DateInputSegmentContext,\n DateInputSegmentGroup,\n}\n"
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "dialog",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {
6
+ "@ark-ui/react": "^5.39.1",
7
+ "lucide-react": "^1.41.0"
8
+ },
9
+ "registryDependencies": [
10
+ "button",
11
+ "utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/dialog.tsx",
16
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { Dialog as DialogPrimitive, Portal as PortalPrimitive } from \"@ark-ui/react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { XIcon } from \"lucide-react\"\n\nfunction Dialog({\n lazyMount = true,\n unmountOnExit = true,\n ...props\n}: React.ComponentProps<typeof DialogPrimitive.Root>) {\n return <DialogPrimitive.Root lazyMount={lazyMount} unmountOnExit={unmountOnExit} {...props} />\n}\n\nfunction DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />\n}\n\nfunction DialogPortal({ ...props }: React.ComponentProps<typeof PortalPrimitive>) {\n return <PortalPrimitive {...props} />\n}\n\nfunction DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.CloseTrigger>) {\n return <DialogPrimitive.CloseTrigger data-slot=\"dialog-close\" {...props} />\n}\n\nfunction DialogContext({ ...props }: React.ComponentProps<typeof DialogPrimitive.Context>) {\n return <DialogPrimitive.Context {...props} />\n}\n\nfunction DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {\n return (\n <DialogPrimitive.Backdrop\n data-slot=\"dialog-overlay\"\n className={cn(\n \"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction DialogPositioner({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Positioner>) {\n return (\n <DialogPrimitive.Positioner\n data-slot=\"dialog-positioner\"\n className={cn(\"fixed inset-0 z-50 flex items-center justify-center\", className)}\n {...props}\n />\n )\n}\n\nfunction DialogContent({\n className,\n children,\n showCloseButton = true,\n ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n showCloseButton?: boolean\n}) {\n return (\n <DialogPortal>\n <DialogOverlay />\n <DialogPositioner>\n <DialogPrimitive.Content\n data-slot=\"dialog-content\"\n className={cn(\n \"relative grid w-full max-w-[calc(100%-2rem)] gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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 {children}\n {showCloseButton && (\n <DialogPrimitive.CloseTrigger data-slot=\"dialog-close\" asChild>\n <Button variant=\"ghost\" className=\"absolute top-2 right-2\" size=\"icon-sm\">\n <XIcon />\n <span className=\"sr-only\">Close</span>\n </Button>\n </DialogPrimitive.CloseTrigger>\n )}\n </DialogPrimitive.Content>\n </DialogPositioner>\n </DialogPortal>\n )\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div data-slot=\"dialog-header\" className={cn(\"flex flex-col gap-2\", className)} {...props} />\n}\n\nfunction DialogFooter({\n className,\n showCloseButton = false,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n showCloseButton?: boolean\n}) {\n return (\n <div\n data-slot=\"dialog-footer\"\n className={cn(\n \"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end\",\n className\n )}\n {...props}\n >\n {children}\n {showCloseButton && (\n <DialogPrimitive.CloseTrigger asChild>\n <Button variant=\"outline\">Close</Button>\n </DialogPrimitive.CloseTrigger>\n )}\n </div>\n )\n}\n\nfunction DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {\n return (\n <DialogPrimitive.Title\n data-slot=\"dialog-title\"\n className={cn(\"font-heading text-base leading-none font-medium\", className)}\n {...props}\n />\n )\n}\n\nfunction DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {\n return (\n <DialogPrimitive.Description\n data-slot=\"dialog-description\"\n className={cn(\n \"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground\",\n className\n )}\n {...props}\n />\n )\n}\n\nexport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogContext,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogOverlay,\n DialogPortal,\n DialogPositioner,\n DialogTitle,\n DialogTrigger,\n}\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "direction",
3
+ "type": "ui",
4
+ "description": "",
5
+ "dependencies": {},
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "components/ui/direction.tsx",
10
+ "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype Direction = \"ltr\" | \"rtl\"\n\n/**\n * Ark UI has no global direction provider; each Ark component accepts a `dir`\n * prop and its `LocaleProvider` derives direction from a locale. This mirrors\n * Radix's `DirectionProvider`: a plain React context you can read with\n * `useDirection` and forward to Ark components as `dir`.\n */\nconst DirectionContext = React.createContext<Direction | undefined>(undefined)\n\nfunction DirectionProvider({\n dir,\n direction,\n children,\n}: {\n dir?: Direction\n direction?: Direction\n children?: React.ReactNode\n}) {\n return <DirectionContext.Provider value={direction ?? dir}>{children}</DirectionContext.Provider>\n}\n\nfunction useDirection(localDir?: Direction): Direction {\n const globalDir = React.useContext(DirectionContext)\n return localDir ?? globalDir ?? \"ltr\"\n}\n\nexport { DirectionProvider, useDirection }\n"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "drag-preview",
3
+ "type": "lib",
4
+ "description": "`src/components/ui/kanban.tsx` is a compositional, data-agnostic board on `@atlaskit/pragmatic-drag-and-drop` (+ `-hitbox` closest-edge/reorder index, `-auto-scroll`).",
5
+ "dependencies": {
6
+ "@atlaskit/pragmatic-drag-and-drop": "^3.1.0"
7
+ },
8
+ "registryDependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "lib/drag-preview.ts",
12
+ "content": "import { preserveOffsetOnSource } from \"@atlaskit/pragmatic-drag-and-drop/utils/preserve-offset-on-source\"\nimport { setCustomNativeDragPreview } from \"@atlaskit/pragmatic-drag-and-drop/utils/set-custom-native-drag-preview\"\nimport type { ElementEventBasePayload } from \"@atlaskit/pragmatic-drag-and-drop/element/adapter\"\n\ntype GeneratePreviewArgs = ElementEventBasePayload & {\n nativeSetDragImage: ((image: Element, x: number, y: number) => void) | null\n}\n\n/**\n * Render a crisp clone of the dragged element as the native drag preview,\n * keeping the pointer at the same offset it had on the source. Use from a\n * draggable's `onGenerateDragPreview`.\n */\nexport function cloneDragPreview(\n { source, location, nativeSetDragImage }: GeneratePreviewArgs,\n options: { className?: string; element?: HTMLElement } = {}\n) {\n const element = options.element ?? source.element\n const rect = element.getBoundingClientRect()\n setCustomNativeDragPreview({\n nativeSetDragImage,\n getOffset: preserveOffsetOnSource({ element, input: location.current.input }),\n render({ container }) {\n const clone = element.cloneNode(true) as HTMLElement\n clone.removeAttribute(\"data-dragging\")\n clone.removeAttribute(\"data-grabbed\")\n clone.style.width = `${rect.width}px`\n clone.style.height = `${rect.height}px`\n clone.style.boxSizing = \"border-box\"\n clone.style.pointerEvents = \"none\"\n clone.classList.add(\"shadow-xl\", \"rotate-1\", \"opacity-95\")\n if (options.className) clone.classList.add(...options.className.split(\" \").filter(Boolean))\n container.appendChild(clone)\n return () => {\n container.removeChild(clone)\n }\n },\n })\n}\n"
13
+ }
14
+ ],
15
+ "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`)."
16
+ }