@visulima/tui-kit 0.0.0 → 1.0.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 (371) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE.md +2095 -0
  3. package/README.md +115 -29
  4. package/dist/accordion.d.ts +59 -0
  5. package/dist/accordion.js +1 -0
  6. package/dist/alert.d.ts +26 -0
  7. package/dist/alert.js +1 -0
  8. package/dist/animate-presence.d.ts +18 -0
  9. package/dist/animate-presence.js +1 -0
  10. package/dist/approval-prompt.d.ts +65 -0
  11. package/dist/approval-prompt.js +1 -0
  12. package/dist/area-chart.d.ts +66 -0
  13. package/dist/area-chart.js +1 -0
  14. package/dist/badge.d.ts +22 -0
  15. package/dist/badge.js +1 -0
  16. package/dist/bar-chart.d.ts +67 -0
  17. package/dist/bar-chart.js +1 -0
  18. package/dist/big-text.d.ts +81 -0
  19. package/dist/big-text.js +1 -0
  20. package/dist/blink-dot.d.ts +36 -0
  21. package/dist/blink-dot.js +1 -0
  22. package/dist/breadcrumb.d.ts +33 -0
  23. package/dist/breadcrumb.js +1 -0
  24. package/dist/button.d.ts +45 -0
  25. package/dist/button.js +1 -0
  26. package/dist/calendar.d.ts +72 -0
  27. package/dist/calendar.js +1 -0
  28. package/dist/card.d.ts +57 -0
  29. package/dist/card.js +1 -0
  30. package/dist/checkbox-group.d.ts +59 -0
  31. package/dist/checkbox-group.js +1 -0
  32. package/dist/checkbox.d.ts +48 -0
  33. package/dist/checkbox.js +1 -0
  34. package/dist/code.d.ts +48 -0
  35. package/dist/code.js +2 -0
  36. package/dist/collapsible.d.ts +53 -0
  37. package/dist/collapsible.js +1 -0
  38. package/dist/color-picker.d.ts +71 -0
  39. package/dist/color-picker.js +1 -0
  40. package/dist/command-block.d.ts +47 -0
  41. package/dist/command-block.js +4 -0
  42. package/dist/command-palette.d.ts +82 -0
  43. package/dist/command-palette.js +1 -0
  44. package/dist/confirm-dialog.d.ts +75 -0
  45. package/dist/confirm-dialog.js +1 -0
  46. package/dist/confirm-input.d.ts +38 -0
  47. package/dist/confirm-input.js +1 -0
  48. package/dist/console-overlay.d.ts +54 -0
  49. package/dist/console-overlay.js +1 -0
  50. package/dist/content-switcher.d.ts +57 -0
  51. package/dist/content-switcher.js +1 -0
  52. package/dist/controlled-scroll-view.d.ts +3 -0
  53. package/dist/controlled-scroll-view.js +1 -0
  54. package/dist/data-grid.d.ts +49 -0
  55. package/dist/data-grid.js +1 -0
  56. package/dist/date-picker.d.ts +65 -0
  57. package/dist/date-picker.js +1 -0
  58. package/dist/definition-list.d.ts +44 -0
  59. package/dist/definition-list.js +1 -0
  60. package/dist/dialog.d.ts +110 -0
  61. package/dist/dialog.js +1 -0
  62. package/dist/diff-view.d.ts +85 -0
  63. package/dist/diff-view.js +1 -0
  64. package/dist/digits.d.ts +30 -0
  65. package/dist/digits.js +1 -0
  66. package/dist/directory-tree.d.ts +51 -0
  67. package/dist/directory-tree.js +1 -0
  68. package/dist/divider.d.ts +49 -0
  69. package/dist/divider.js +1 -0
  70. package/dist/drawer.d.ts +53 -0
  71. package/dist/drawer.js +1 -0
  72. package/dist/file-change.d.ts +40 -0
  73. package/dist/file-change.js +1 -0
  74. package/dist/file-picker-entry.d.ts +2 -0
  75. package/dist/file-picker-entry.js +1 -0
  76. package/dist/form.d.ts +81 -0
  77. package/dist/form.js +1 -0
  78. package/dist/gauge.d.ts +87 -0
  79. package/dist/gauge.js +1 -0
  80. package/dist/gradient.d.ts +42 -0
  81. package/dist/gradient.js +1 -0
  82. package/dist/grid.d.ts +40 -0
  83. package/dist/grid.js +1 -0
  84. package/dist/heading.d.ts +33 -0
  85. package/dist/heading.js +1 -0
  86. package/dist/heatmap.d.ts +62 -0
  87. package/dist/heatmap.js +1 -0
  88. package/dist/help.d.ts +52 -0
  89. package/dist/help.js +1 -0
  90. package/dist/histogram.d.ts +62 -0
  91. package/dist/histogram.js +1 -0
  92. package/dist/image.d.ts +26 -0
  93. package/dist/image.js +1 -0
  94. package/dist/index.d.ts +125 -0
  95. package/dist/index.js +1 -0
  96. package/dist/json.d.ts +58 -0
  97. package/dist/json.js +1 -0
  98. package/dist/kbd.d.ts +31 -0
  99. package/dist/kbd.js +1 -0
  100. package/dist/line-chart.d.ts +109 -0
  101. package/dist/line-chart.js +1 -0
  102. package/dist/link.d.ts +38 -0
  103. package/dist/link.js +1 -0
  104. package/dist/loading-indicator.d.ts +29 -0
  105. package/dist/loading-indicator.js +1 -0
  106. package/dist/log.d.ts +38 -0
  107. package/dist/log.js +1 -0
  108. package/dist/markdown.d.ts +40 -0
  109. package/dist/markdown.js +1 -0
  110. package/dist/masked-input.d.ts +50 -0
  111. package/dist/masked-input.js +1 -0
  112. package/dist/menu.d.ts +78 -0
  113. package/dist/menu.js +1 -0
  114. package/dist/message-bubble.d.ts +43 -0
  115. package/dist/message-bubble.js +1 -0
  116. package/dist/model-badge.d.ts +39 -0
  117. package/dist/model-badge.js +1 -0
  118. package/dist/model-selector.d.ts +52 -0
  119. package/dist/model-selector.js +1 -0
  120. package/dist/multi-progress.d.ts +55 -0
  121. package/dist/multi-progress.js +1 -0
  122. package/dist/multi-select.d.ts +65 -0
  123. package/dist/multi-select.js +1 -0
  124. package/dist/notification-badge.d.ts +47 -0
  125. package/dist/notification-badge.js +1 -0
  126. package/dist/number-input.d.ts +85 -0
  127. package/dist/number-input.js +1 -0
  128. package/dist/operation-tree.d.ts +49 -0
  129. package/dist/operation-tree.js +1 -0
  130. package/dist/option-list.d.ts +47 -0
  131. package/dist/option-list.js +1 -0
  132. package/dist/ordered-list.d.ts +24 -0
  133. package/dist/ordered-list.js +1 -0
  134. package/dist/packem_shared/ControlledScrollView-BzlbIUph.js +1 -0
  135. package/dist/packem_shared/FilePicker-V5H2fb-v.js +1 -0
  136. package/dist/packem_shared/ScrollBar-C9i3z3Ft.js +1 -0
  137. package/dist/packem_shared/ScrollBarBox-DZEC8YnV.js +1 -0
  138. package/dist/packem_shared/ScrollList-Djtb2vVy.js +1 -0
  139. package/dist/packem_shared/ScrollView-ESxpl6Hy.js +1 -0
  140. package/dist/packem_shared/TreeNodeMap-cJlYqckB.js +1 -0
  141. package/dist/packem_shared/TreeView-q0l446NV.js +1 -0
  142. package/dist/packem_shared/controlled-scroll-view-DerVKKv6.js +1 -0
  143. package/dist/packem_shared/controlled-scroll-view.d-vF4wevEJ.d.ts +54 -0
  144. package/dist/packem_shared/file-picker.d-Cie6aAMD.d.ts +104 -0
  145. package/dist/packem_shared/line-chart-BbhMhcZg.js +1 -0
  146. package/dist/packem_shared/scroll-bar-box.d-DSBbBsRc.d.ts +30 -0
  147. package/dist/packem_shared/scroll-bar.d-B4sD1hBR.d.ts +18 -0
  148. package/dist/packem_shared/scroll-list.d-BP0UZSY4.d.ts +12 -0
  149. package/dist/packem_shared/scroll-view.d-BqKpI-F-.d.ts +119 -0
  150. package/dist/packem_shared/treeViewTheme-Dq3ekRAC.js +1 -0
  151. package/dist/packem_shared/use-tree-view.d-dXTKoDPM.d.ts +350 -0
  152. package/dist/packem_shared/useTreeView-D0JEqT6W.js +1 -0
  153. package/dist/packem_shared/useTreeViewState-BiTJakkh.js +1 -0
  154. package/dist/packem_shared/variant-config-BJqpNo7H.js +1 -0
  155. package/dist/packem_shared/variant-config.d-D5NnrrQK.d.ts +2 -0
  156. package/dist/paginator.d.ts +69 -0
  157. package/dist/paginator.js +1 -0
  158. package/dist/paragraph.d.ts +38 -0
  159. package/dist/paragraph.js +1 -0
  160. package/dist/path-input.d.ts +67 -0
  161. package/dist/path-input.js +1 -0
  162. package/dist/pie-chart.d.ts +50 -0
  163. package/dist/pie-chart.js +1 -0
  164. package/dist/placeholder.d.ts +55 -0
  165. package/dist/placeholder.js +1 -0
  166. package/dist/popover.d.ts +46 -0
  167. package/dist/popover.js +1 -0
  168. package/dist/progress-bar.d.ts +51 -0
  169. package/dist/progress-bar.js +1 -0
  170. package/dist/progress-circle.d.ts +59 -0
  171. package/dist/progress-circle.js +1 -0
  172. package/dist/qr-code.d.ts +45 -0
  173. package/dist/qr-code.js +1 -0
  174. package/dist/r/accordion.json +24 -0
  175. package/dist/r/alert.json +24 -0
  176. package/dist/r/animate-presence.json +22 -0
  177. package/dist/r/approval-prompt.json +24 -0
  178. package/dist/r/area-chart.json +27 -0
  179. package/dist/r/badge.json +24 -0
  180. package/dist/r/bar-chart.json +24 -0
  181. package/dist/r/big-text.json +23 -0
  182. package/dist/r/blink-dot.json +24 -0
  183. package/dist/r/breadcrumb.json +24 -0
  184. package/dist/r/button.json +24 -0
  185. package/dist/r/calendar.json +24 -0
  186. package/dist/r/card.json +26 -0
  187. package/dist/r/chart-utils.json +24 -0
  188. package/dist/r/checkbox-group.json +24 -0
  189. package/dist/r/checkbox.json +24 -0
  190. package/dist/r/code.json +23 -0
  191. package/dist/r/collapsible.json +24 -0
  192. package/dist/r/color-picker.json +24 -0
  193. package/dist/r/command-block.json +26 -0
  194. package/dist/r/command-palette.json +24 -0
  195. package/dist/r/confirm-dialog.json +24 -0
  196. package/dist/r/confirm-input.json +22 -0
  197. package/dist/r/console-overlay.json +22 -0
  198. package/dist/r/content-switcher.json +24 -0
  199. package/dist/r/controlled-scroll-view.json +24 -0
  200. package/dist/r/data-grid.json +24 -0
  201. package/dist/r/date-picker.json +26 -0
  202. package/dist/r/definition-list.json +24 -0
  203. package/dist/r/dialog.json +24 -0
  204. package/dist/r/diff-view.json +24 -0
  205. package/dist/r/digits.json +24 -0
  206. package/dist/r/directory-tree.json +24 -0
  207. package/dist/r/divider.json +24 -0
  208. package/dist/r/drawer.json +24 -0
  209. package/dist/r/file-change.json +24 -0
  210. package/dist/r/file-picker.json +44 -0
  211. package/dist/r/form.json +24 -0
  212. package/dist/r/gauge.json +24 -0
  213. package/dist/r/gradient.json +24 -0
  214. package/dist/r/grid.json +22 -0
  215. package/dist/r/heading.json +24 -0
  216. package/dist/r/heatmap.json +24 -0
  217. package/dist/r/help.json +22 -0
  218. package/dist/r/histogram.json +26 -0
  219. package/dist/r/image.json +24 -0
  220. package/dist/r/json.json +24 -0
  221. package/dist/r/kbd.json +24 -0
  222. package/dist/r/line-chart.json +26 -0
  223. package/dist/r/link.json +23 -0
  224. package/dist/r/loading-indicator.json +27 -0
  225. package/dist/r/log.json +24 -0
  226. package/dist/r/markdown.json +29 -0
  227. package/dist/r/masked-input.json +22 -0
  228. package/dist/r/menu.json +24 -0
  229. package/dist/r/message-bubble.json +24 -0
  230. package/dist/r/model-badge.json +24 -0
  231. package/dist/r/model-selector.json +24 -0
  232. package/dist/r/multi-progress.json +24 -0
  233. package/dist/r/multi-select.json +24 -0
  234. package/dist/r/notification-badge.json +24 -0
  235. package/dist/r/number-input.json +24 -0
  236. package/dist/r/operation-tree.json +26 -0
  237. package/dist/r/option-list.json +24 -0
  238. package/dist/r/ordered-list.json +22 -0
  239. package/dist/r/paginator.json +22 -0
  240. package/dist/r/paragraph.json +24 -0
  241. package/dist/r/path-input.json +26 -0
  242. package/dist/r/pie-chart.json +24 -0
  243. package/dist/r/placeholder.json +24 -0
  244. package/dist/r/popover.json +24 -0
  245. package/dist/r/progress-bar.json +22 -0
  246. package/dist/r/progress-circle.json +24 -0
  247. package/dist/r/qr-code.json +24 -0
  248. package/dist/r/radio-group.json +24 -0
  249. package/dist/r/registry.json +1051 -0
  250. package/dist/r/scatter-plot.json +27 -0
  251. package/dist/r/scroll-bar-box.json +24 -0
  252. package/dist/r/scroll-bar.json +24 -0
  253. package/dist/r/scroll-list.json +24 -0
  254. package/dist/r/scroll-view.json +24 -0
  255. package/dist/r/scroll.json +58 -0
  256. package/dist/r/search-input.json +26 -0
  257. package/dist/r/select-input-indicator.json +24 -0
  258. package/dist/r/select-input-item.json +24 -0
  259. package/dist/r/select-input.json +28 -0
  260. package/dist/r/shimmer-text.json +24 -0
  261. package/dist/r/sidebar.json +24 -0
  262. package/dist/r/slider.json +24 -0
  263. package/dist/r/sparkline.json +24 -0
  264. package/dist/r/spinner.json +23 -0
  265. package/dist/r/status-line.json +24 -0
  266. package/dist/r/status-message.json +24 -0
  267. package/dist/r/stepper.json +24 -0
  268. package/dist/r/stopwatch.json +22 -0
  269. package/dist/r/streaming-text.json +24 -0
  270. package/dist/r/switch.json +24 -0
  271. package/dist/r/tab.json +22 -0
  272. package/dist/r/table.json +23 -0
  273. package/dist/r/tabs.json +26 -0
  274. package/dist/r/tag-input.json +24 -0
  275. package/dist/r/tag.json +24 -0
  276. package/dist/r/text-input.json +22 -0
  277. package/dist/r/textarea.json +22 -0
  278. package/dist/r/thinking-block.json +24 -0
  279. package/dist/r/time-picker.json +24 -0
  280. package/dist/r/timer.json +22 -0
  281. package/dist/r/toast.json +24 -0
  282. package/dist/r/token-usage.json +24 -0
  283. package/dist/r/tool-call.json +24 -0
  284. package/dist/r/tooltip.json +24 -0
  285. package/dist/r/transition.json +22 -0
  286. package/dist/r/tree-select.json +24 -0
  287. package/dist/r/tree-view.json +64 -0
  288. package/dist/r/unordered-list.json +22 -0
  289. package/dist/r/use-linked-scroll.json +24 -0
  290. package/dist/r/variant-config.json +22 -0
  291. package/dist/radio-group.d.ts +74 -0
  292. package/dist/radio-group.js +1 -0
  293. package/dist/scatter-plot.d.ts +35 -0
  294. package/dist/scatter-plot.js +1 -0
  295. package/dist/scroll-bar-box.d.ts +3 -0
  296. package/dist/scroll-bar-box.js +1 -0
  297. package/dist/scroll-bar.d.ts +3 -0
  298. package/dist/scroll-bar.js +1 -0
  299. package/dist/scroll-list.d.ts +4 -0
  300. package/dist/scroll-list.js +1 -0
  301. package/dist/scroll-view.d.ts +3 -0
  302. package/dist/scroll-view.js +1 -0
  303. package/dist/search-input.d.ts +55 -0
  304. package/dist/search-input.js +1 -0
  305. package/dist/select-input-indicator.d.ts +32 -0
  306. package/dist/select-input-indicator.js +1 -0
  307. package/dist/select-input-item.d.ts +41 -0
  308. package/dist/select-input-item.js +1 -0
  309. package/dist/select-input.d.ts +110 -0
  310. package/dist/select-input.js +1 -0
  311. package/dist/shimmer-text.d.ts +47 -0
  312. package/dist/shimmer-text.js +1 -0
  313. package/dist/sidebar.d.ts +47 -0
  314. package/dist/sidebar.js +1 -0
  315. package/dist/slider.d.ts +103 -0
  316. package/dist/slider.js +1 -0
  317. package/dist/sparkline.d.ts +42 -0
  318. package/dist/sparkline.js +1 -0
  319. package/dist/spinner.d.ts +17 -0
  320. package/dist/spinner.js +1 -0
  321. package/dist/status-line.d.ts +37 -0
  322. package/dist/status-line.js +1 -0
  323. package/dist/status-message.d.ts +21 -0
  324. package/dist/status-message.js +1 -0
  325. package/dist/stepper.d.ts +48 -0
  326. package/dist/stepper.js +1 -0
  327. package/dist/stopwatch.d.ts +49 -0
  328. package/dist/stopwatch.js +1 -0
  329. package/dist/streaming-text.d.ts +46 -0
  330. package/dist/streaming-text.js +1 -0
  331. package/dist/switch.d.ts +55 -0
  332. package/dist/switch.js +1 -0
  333. package/dist/tab.d.ts +19 -0
  334. package/dist/tab.js +1 -0
  335. package/dist/table.d.ts +119 -0
  336. package/dist/table.js +1 -0
  337. package/dist/tabs.d.ts +107 -0
  338. package/dist/tabs.js +1 -0
  339. package/dist/tag-input.d.ts +71 -0
  340. package/dist/tag-input.js +1 -0
  341. package/dist/tag.d.ts +35 -0
  342. package/dist/tag.js +1 -0
  343. package/dist/text-input.d.ts +48 -0
  344. package/dist/text-input.js +1 -0
  345. package/dist/textarea.d.ts +68 -0
  346. package/dist/textarea.js +2 -0
  347. package/dist/thinking-block.d.ts +57 -0
  348. package/dist/thinking-block.js +1 -0
  349. package/dist/time-picker.d.ts +67 -0
  350. package/dist/time-picker.js +1 -0
  351. package/dist/timer.d.ts +59 -0
  352. package/dist/timer.js +1 -0
  353. package/dist/toast.d.ts +49 -0
  354. package/dist/toast.js +1 -0
  355. package/dist/token-usage.d.ts +50 -0
  356. package/dist/token-usage.js +1 -0
  357. package/dist/tool-call.d.ts +33 -0
  358. package/dist/tool-call.js +1 -0
  359. package/dist/tooltip.d.ts +43 -0
  360. package/dist/tooltip.js +1 -0
  361. package/dist/transition.d.ts +74 -0
  362. package/dist/transition.js +1 -0
  363. package/dist/tree-select.d.ts +65 -0
  364. package/dist/tree-select.js +1 -0
  365. package/dist/tree-view-entry.d.ts +4 -0
  366. package/dist/tree-view-entry.js +1 -0
  367. package/dist/unordered-list.d.ts +30 -0
  368. package/dist/unordered-list.js +1 -0
  369. package/dist/use-linked-scroll.d.ts +42 -0
  370. package/dist/use-linked-scroll.js +1 -0
  371. package/package.json +553 -7
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "calendar",
4
+ "type": "registry:ui",
5
+ "title": "Calendar",
6
+ "description": "Calendar component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color for today / the focused cell.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus the grid on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Initial focused day when uncontrolled. Defaults to `value` or today.\n */\n readonly defaultValue?: Date;\n\n /**\n * First day of the week: 0 = Sunday, 1 = Monday.\n * @default 1\n */\n readonly firstDayOfWeek?: 0 | 1;\n\n /**\n * When true, disable input and render the grid dim.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Upper bound; days after `maxDate` cannot be selected.\n */\n readonly maxDate?: Date;\n\n /**\n * Lower bound; days before `minDate` cannot be selected.\n */\n readonly minDate?: Date;\n\n /**\n * Called whenever the user navigates or selects a day.\n */\n readonly onChange?: (date: Date) => void;\n\n /**\n * Called when the user presses Enter.\n */\n readonly onSubmit?: (date: Date) => void;\n\n /**\n * Color used to mark the currently selected day.\n * @default \"green\"\n */\n readonly selectedColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Controlled selected date.\n */\n readonly value?: Date;\n};\n\nconst MONTH_NAMES = [\"January\", \"February\", \"March\", \"April\", \"May\", \"June\", \"July\", \"August\", \"September\", \"October\", \"November\", \"December\"] as const;\n\nconst WEEKDAYS = [\"Su\", \"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\"] as const;\n\nconst startOfDay = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), date.getDate());\n\n/**\n * Move `date` by `delta` months while clamping the day so end-of-month\n * navigation does not roll over (e.g., Jan 31 + 1 month becomes Feb 28/29\n * instead of overflowing to March 3).\n */\nconst shiftMonth = (date: Date, delta: number): Date => {\n const targetYear = date.getFullYear();\n const targetMonth = date.getMonth() + delta;\n // `new Date(year, monthIndex + 1, 0)` returns the last day of `monthIndex`.\n const lastDay = new Date(targetYear, targetMonth + 1, 0).getDate();\n const day = Math.min(date.getDate(), lastDay);\n\n return new Date(targetYear, targetMonth, day);\n};\n\nconst isSameDay = (a: Date, b: Date): boolean => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\n\nconst isOutOfRange = (date: Date, minDate: Date | undefined, maxDate: Date | undefined): boolean => {\n if (minDate !== undefined && date < startOfDay(minDate)) {\n return true;\n }\n\n if (maxDate !== undefined && date > startOfDay(maxDate)) {\n return true;\n }\n\n return false;\n};\n\nconst buildWeeks = (year: number, month: number, firstDayOfWeek: 0 | 1): ReadonlyArray<ReadonlyArray<Date | undefined>> => {\n const firstOfMonth = new Date(year, month, 1);\n const firstDow = firstOfMonth.getDay();\n const leadingBlanks = (firstDow - firstDayOfWeek + 7) % 7;\n\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n\n const cells: (Date | undefined)[] = [];\n\n for (let index = 0; index < leadingBlanks; index += 1) {\n cells.push(undefined);\n }\n\n for (let day = 1; day <= daysInMonth; day += 1) {\n cells.push(new Date(year, month, day));\n }\n\n while (cells.length % 7 !== 0) {\n cells.push(undefined);\n }\n\n const weeks: ReadonlyArray<Date | undefined>[] = [];\n\n for (let row = 0; row < cells.length; row += 7) {\n weeks.push(cells.slice(row, row + 7));\n }\n\n return weeks;\n};\n\n/**\n * Month-grid date picker. Arrow keys move focus by one day, page up/down move\n * by month. Enter submits; Space or Enter selects.\n * @returns A bordered `ReactElement` containing the month header, weekday\n * labels, and the day grid.\n */\nexport default function Calendar({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue,\n firstDayOfWeek = 1,\n isDisabled = false,\n maxDate,\n minDate,\n onChange,\n onSubmit,\n selectedColor = \"green\",\n value,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n // eslint-disable-next-line no-restricted-syntax -- today must be stable across renders; useRef would lose semantic clarity\n const today = useMemo(() => startOfDay(new Date()), []);\n const initial = startOfDay(value ?? defaultValue ?? today);\n const [internalCursor, setInternalCursor] = useState(initial);\n const cursor = value ? startOfDay(value) : internalCursor;\n const cursorRef = useRef(cursor);\n\n cursorRef.current = cursor;\n\n const weekdayLabels = useMemo(() => [...WEEKDAYS.slice(firstDayOfWeek), ...WEEKDAYS.slice(0, firstDayOfWeek)], [firstDayOfWeek]);\n\n const weeks = useMemo(() => buildWeeks(cursor.getFullYear(), cursor.getMonth(), firstDayOfWeek), [cursor, firstDayOfWeek]);\n\n const moveCursor = useCallback(\n (next: Date) => {\n const clamped = startOfDay(next);\n\n if (isOutOfRange(clamped, minDate, maxDate)) {\n return;\n }\n\n if (value === undefined) {\n setInternalCursor(clamped);\n }\n\n onChange?.(clamped);\n },\n [value, onChange, minDate, maxDate],\n );\n\n useInput(\n useCallback(\n (_, key) => {\n if (!isFocused) {\n return;\n }\n\n const { current } = cursorRef;\n\n if (key.leftArrow) {\n moveCursor(new Date(current.getFullYear(), current.getMonth(), current.getDate() - 1));\n } else if (key.rightArrow) {\n moveCursor(new Date(current.getFullYear(), current.getMonth(), current.getDate() + 1));\n } else if (key.upArrow) {\n moveCursor(new Date(current.getFullYear(), current.getMonth(), current.getDate() - 7));\n } else if (key.downArrow) {\n moveCursor(new Date(current.getFullYear(), current.getMonth(), current.getDate() + 7));\n } else if (key.pageUp) {\n moveCursor(shiftMonth(current, -1));\n } else if (key.pageDown) {\n moveCursor(shiftMonth(current, 1));\n } else if (key.return) {\n onSubmit?.(current);\n }\n },\n [isFocused, moveCursor, onSubmit],\n ),\n { isActive: !isDisabled && isFocused },\n );\n\n const monthLabel = `${MONTH_NAMES[cursor.getMonth()]} ${cursor.getFullYear()}`;\n\n return (\n <Box borderColor={isFocused ? accentColor : undefined} borderStyle=\"round\" flexDirection=\"column\" paddingX={1}>\n <Box justifyContent=\"center\">\n <Text bold color={accentColor}>\n {monthLabel}\n </Text>\n </Box>\n <Box marginTop={1}>\n {weekdayLabels.map((name) => (\n <Box key={name} width={3}>\n <Text dimColor>{name.padEnd(2, \" \")}</Text>\n </Box>\n ))}\n </Box>\n {weeks.map((week, weekIndex) => (\n // eslint-disable-next-line react-x/no-array-index-key -- calendar grid is positionally stable\n <Box key={weekIndex}>\n {week.map((day, dayIndex) => {\n if (day === undefined) {\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- calendar grid is positionally stable\n <Box key={dayIndex} width={3}>\n <Text>{\" \"}</Text>\n </Box>\n );\n }\n\n const isToday = isSameDay(day, today);\n const isCursor = isSameDay(day, cursor);\n const outOfRange = isOutOfRange(day, minDate, maxDate);\n const focusedColor = isToday ? selectedColor : undefined;\n const dayColor = isCursor && isFocused ? \"black\" : focusedColor;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- calendar grid is positionally stable\n <Box key={dayIndex} width={3}>\n <Text\n backgroundColor={isCursor && isFocused ? accentColor : undefined}\n bold={isToday}\n color={dayColor}\n dimColor={outOfRange || isDisabled}\n >\n {String(day.getDate()).padStart(2, \" \")}\n{\" \"}\n </Text>\n </Box>\n );\n })}\n </Box>\n ))}\n </Box>\n );\n}\n\nexport { Calendar };\nexport type { Props as CalendarProps };\n",
19
+ "path": "registry/ui/calendar.tsx",
20
+ "target": "components/ui/calendar.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "card",
4
+ "type": "registry:ui",
5
+ "title": "Card",
6
+ "description": "Card component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [
16
+ "https://visulima.com/r/divider.json"
17
+ ],
18
+ "files": [
19
+ {
20
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport Divider from \"@/components/ui/divider\";\n\nexport type Props = {\n /**\n * Border color.\n */\n readonly borderColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Border style name, passed through to `Box`.\n * @default \"round\"\n */\n readonly borderStyle?: \"arrow\" | \"bold\" | \"classic\" | \"double\" | \"doubleSingle\" | \"round\" | \"single\" | \"singleDouble\";\n\n /**\n * Card body.\n */\n readonly children: ReactNode;\n\n /**\n * Optional footer rendered below body with a dim divider.\n */\n readonly footer?: ReactNode;\n\n /**\n * Optional content rendered after the title on the same row.\n */\n readonly headerRight?: ReactNode;\n\n /**\n * Optional subtitle rendered below the title.\n */\n readonly subtitle?: string;\n\n /**\n * Optional title rendered in the header area.\n */\n readonly title?: string;\n\n /**\n * Color applied to the title.\n */\n readonly titleColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Fix the card width. When omitted the card grows to fill its parent.\n */\n readonly width?: number | string;\n};\n\n/**\n * Bordered container with optional title, subtitle, and footer.\n */\nexport default function Card({ borderColor, borderStyle = \"round\", children, footer, headerRight, subtitle, title, titleColor, width }: Props): ReactElement {\n const hasHeader = title !== undefined || subtitle !== undefined || headerRight !== undefined;\n\n return (\n <Box borderColor={borderColor} borderStyle={borderStyle} flexDirection=\"column\" paddingX={1} width={width}>\n {hasHeader\n ? (\n <Box flexDirection=\"column\" marginBottom={1}>\n <Box justifyContent=\"space-between\">\n <Box>\n {title === undefined\n ? undefined\n : (\n <Text bold color={titleColor}>\n {title}\n </Text>\n )}\n </Box>\n {headerRight === undefined ? undefined : <Box>{headerRight}</Box>}\n </Box>\n {subtitle === undefined ? undefined : <Text dimColor>{subtitle}</Text>}\n </Box>\n )\n : undefined}\n <Box flexDirection=\"column\">{children}</Box>\n {footer === undefined\n ? undefined\n : (\n <Box flexDirection=\"column\" marginTop={1}>\n <Divider dimColor length={typeof width === \"number\" ? Math.max(1, width - 4) : undefined} />\n <Box marginTop={1}>{footer}</Box>\n </Box>\n )}\n </Box>\n );\n}\n\nexport { Card };\nexport type { Props as CardProps };\n",
21
+ "path": "registry/ui/card.tsx",
22
+ "target": "components/ui/card.tsx",
23
+ "type": "registry:ui"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "chart-utils",
4
+ "type": "registry:lib",
5
+ "title": "Chart Utils",
6
+ "description": "Chart Utils component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "import type { AnsiColors } from \"@visulima/colorize\";\nimport type { LiteralUnion } from \"type-fest\";\n\n/**\n * A single 2D data point.\n */\nexport type Point = { readonly x: number; readonly y: number };\n\n/**\n * Minimal shape every \"series-like\" chart entry conforms to. Used so chart\n * components share utilities without coupling to a specific Props type.\n */\nexport type SeriesLike = {\n readonly color?: LiteralUnion<AnsiColors, string>;\n readonly data: ReadonlyArray<number> | ReadonlyArray<Point>;\n readonly label?: string;\n};\n\n/**\n * Default palette cycled through when a series does not declare its own color.\n */\nexport const DEFAULT_CHART_PALETTE: ReadonlyArray<LiteralUnion<AnsiColors, string>> = [\"cyan\", \"magenta\", \"yellow\", \"green\", \"blue\", \"red\"];\n\n/**\n * Normalize the per-series `data` prop into a uniform `Point[]`. Numeric\n * arrays are treated as y-values with their index as x.\n * @param data Either a numeric array or an array of `{x, y}` points.\n * @returns A uniform `Point[]` — empty when no samples were provided.\n */\nexport const toPoints = (data: SeriesLike[\"data\"]): ReadonlyArray<Point> => {\n if (data.length === 0) {\n return [];\n }\n\n const first = data[0];\n\n if (typeof first === \"number\") {\n return (data as ReadonlyArray<number>).map((y, x) => {\n return { x, y };\n });\n }\n\n return data as ReadonlyArray<Point>;\n};\n\n/**\n * Pick the effective color for a series: per-series override, else palette\n * cycling by index, with a safe `\"cyan\"` fallback.\n * @param series The series whose color we want.\n * @param index The series index for palette cycling.\n * @param palette Fallback palette.\n * @returns The resolved color string.\n */\nexport const pickSeriesColor = (\n series: SeriesLike,\n index: number,\n palette: ReadonlyArray<LiteralUnion<AnsiColors, string>>,\n): LiteralUnion<AnsiColors, string> => series.color ?? palette[index % palette.length] ?? \"cyan\";\n\nexport type Extents = {\n readonly xMax: number;\n readonly xMin: number;\n readonly yMax: number;\n readonly yMin: number;\n};\n\n/**\n * Single-pass min/max scan for an arbitrary collection of series. Returns\n * `Infinity` / `-Infinity` sentinels when no points are provided; callers\n * should replace these with their chosen defaults before rendering.\n * @param seriesList Iterable of series whose projected `points` need extents.\n * @returns The `{xMin, xMax, yMin, yMax}` tuple across all input points.\n */\nexport const computeExtents = (seriesList: Iterable<{ readonly points: ReadonlyArray<Point> }>): Extents => {\n let xMin = Number.POSITIVE_INFINITY;\n let xMax = Number.NEGATIVE_INFINITY;\n let yMin = Number.POSITIVE_INFINITY;\n let yMax = Number.NEGATIVE_INFINITY;\n\n for (const { points } of seriesList) {\n for (const point of points) {\n if (point.x < xMin) {\n xMin = point.x;\n }\n\n if (point.x > xMax) {\n xMax = point.x;\n }\n\n if (point.y < yMin) {\n yMin = point.y;\n }\n\n if (point.y > yMax) {\n yMax = point.y;\n }\n }\n }\n\n return { xMax, xMin, yMax, yMin };\n};\n",
19
+ "path": "registry/lib/chart-utils.ts",
20
+ "target": "lib/chart-utils.ts",
21
+ "type": "registry:lib"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "checkbox-group",
4
+ "type": "registry:ui",
5
+ "title": "Checkbox Group",
6
+ "description": "Checkbox Group component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nconst EMPTY_VALUES: ReadonlyArray<string> = [];\n\nexport type CheckboxOption = {\n readonly description?: string;\n readonly isDisabled?: boolean;\n readonly label: string;\n readonly value: string;\n};\n\nexport type Props = {\n /**\n * Accent color for the focused row and checked marks.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Checked values when uncontrolled.\n */\n readonly defaultValue?: ReadonlyArray<string>;\n\n /**\n * Disable the whole group.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Fires whenever the set of checked values changes.\n */\n readonly onChange?: (values: ReadonlyArray<string>) => void;\n\n /**\n * Fires on Enter with the current checked values.\n */\n readonly onSubmit?: (values: ReadonlyArray<string>) => void;\n\n /**\n * The options to render.\n */\n readonly options: ReadonlyArray<CheckboxOption>;\n\n /**\n * Controlled checked values. When provided, `defaultValue` is ignored.\n */\n readonly value?: ReadonlyArray<string>;\n};\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\n/**\n * A vertical list of checkboxes. ↑/↓ move focus, Space toggles the focused\n * option, `a` toggles all, and Enter submits.\n */\nexport default function CheckboxGroup({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue = EMPTY_VALUES,\n isDisabled = false,\n onChange,\n onSubmit,\n options,\n value: controlledValue,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const isControlled = controlledValue !== undefined;\n\n const [internal, setInternal] = useState<ReadonlyArray<string>>(defaultValue);\n const checked = useMemo(() => new Set(controlledValue ?? internal), [controlledValue, internal]);\n\n const [cursor, setCursor] = useState(0);\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n const emit = useCallback(\n (next: Set<string>) => {\n const values = options.map((option) => option.value).filter((value) => next.has(value));\n\n if (!isControlled) {\n setInternal(values);\n }\n\n onChangeRef.current?.(values);\n\n return values;\n },\n [isControlled, options],\n );\n\n const inputHandler = useCallback(\n (input: string, key: { downArrow: boolean; return: boolean; upArrow: boolean }) => {\n if (key.upArrow) {\n setCursor((index) => wrap(index - 1, options.length));\n\n return;\n }\n\n if (key.downArrow) {\n setCursor((index) => wrap(index + 1, options.length));\n\n return;\n }\n\n if (input === \" \") {\n const option = options[cursor];\n\n if (option === undefined || option.isDisabled) {\n return;\n }\n\n const next = new Set(checked);\n\n if (next.has(option.value)) {\n next.delete(option.value);\n } else {\n next.add(option.value);\n }\n\n emit(next);\n\n return;\n }\n\n if (input === \"a\") {\n const enabled = options.filter((option) => !option.isDisabled);\n const allChecked = enabled.every((option) => checked.has(option.value));\n // Disabled rows aren't user-mutable: carry their checked state\n // through toggle-all in both directions.\n const next = new Set(options.filter((option) => option.isDisabled && checked.has(option.value)).map((option) => option.value));\n\n if (!allChecked) {\n for (const option of enabled) {\n next.add(option.value);\n }\n }\n\n emit(next);\n\n return;\n }\n\n if (key.return) {\n onSubmit?.(options.map((option) => option.value).filter((value) => checked.has(value)));\n }\n },\n [checked, cursor, emit, onSubmit, options],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n return (\n <Box flexDirection=\"column\">\n {options.map((option, index) => {\n const isActive = isFocused && index === cursor;\n const isChecked = checked.has(option.value);\n const mark = isChecked ? \"◉\" : \"◯\";\n const isHighlighted = !option.isDisabled && (isChecked || isActive);\n const color = isHighlighted ? accentColor : undefined;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- option index is stable for the render\n <Box key={index}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"❯ \" : \" \"}</Text>\n <Text color={color} dimColor={option.isDisabled}>\n {mark}\n {\" \"}\n {option.label}\n </Text>\n {option.description === undefined\n ? undefined\n : (\n <Box marginLeft={1}>\n <Text dimColor>{option.description}</Text>\n </Box>\n )}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { CheckboxGroup };\nexport type { Props as CheckboxGroupProps };\n",
19
+ "path": "registry/ui/checkbox-group.tsx",
20
+ "target": "components/ui/checkbox-group.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "checkbox",
4
+ "type": "registry:ui",
5
+ "title": "Checkbox",
6
+ "description": "Checkbox component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color when focused.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus when mounted if nothing else is focused.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Label rendered next to the checkbox.\n */\n readonly children?: ReactNode;\n\n /**\n * Initial checked state when uncontrolled.\n * @default false\n */\n readonly defaultChecked?: boolean;\n\n /**\n * Controlled checked state. When provided, `onChange` should update the parent.\n */\n readonly isChecked?: boolean;\n\n /**\n * Disable input and dim the label.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Fired when the checkbox toggles.\n */\n readonly onChange?: (checked: boolean) => void;\n};\n\nconst CHECKED_ICON = \"☒\";\nconst UNCHECKED_ICON = \"☐\";\n\n/**\n * Focusable checkbox. Toggles with Space or Enter.\n */\nexport default function Checkbox({\n accentColor = \"blue\",\n autoFocus = false,\n children,\n defaultChecked = false,\n isChecked,\n isDisabled = false,\n onChange,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const [internalChecked, setInternalChecked] = useState(defaultChecked);\n const checked = isChecked ?? internalChecked;\n\n useInput(\n useCallback(\n (input, key) => {\n if (!isFocused) {\n return;\n }\n\n if (key.return || input === \" \") {\n const next = !checked;\n\n if (isChecked === undefined) {\n setInternalChecked(next);\n }\n\n onChange?.(next);\n }\n },\n [isFocused, checked, isChecked, onChange],\n ),\n { isActive: !isDisabled && isFocused },\n );\n\n return (\n <Box gap={1}>\n <Text color={isFocused ? accentColor : undefined} dimColor={isDisabled}>\n {isFocused ? \"▸ \" : \" \"}\n {checked ? CHECKED_ICON : UNCHECKED_ICON}\n </Text>\n {children === undefined\n ? undefined\n : (\n <Text color={isFocused ? accentColor : undefined} dimColor={isDisabled}>\n {children}\n </Text>\n )}\n </Box>\n );\n}\n\nexport { Checkbox };\nexport type { Props as CheckboxProps };\n",
19
+ "path": "registry/ui/checkbox.tsx",
20
+ "target": "components/ui/checkbox.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "code",
4
+ "type": "registry:ui",
5
+ "title": "Code",
6
+ "description": "Code component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui",
12
+ "shiki"
13
+ ],
14
+ "registryDependencies": [],
15
+ "files": [
16
+ {
17
+ "content": "/* eslint-disable consistent-return, no-void, react-x/no-array-index-key, react-you-might-not-need-an-effect/no-adjust-state-on-prop-change, react/function-component-definition */\n\n/**\n * Syntax-highlighted code display component for Ink.\n *\n * Uses Shiki for tokenization and maps themed tokens to colored\n * Ink Text elements for terminal rendering.\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { TokenRenderOptions } from \"@visulima/tui/highlighter\";\nimport { getCachedTokens, getHighlighter, isLanguageSupported, resolveLanguage, tokenLinesToElements } from \"@visulima/tui/highlighter\";\nimport type { ReactElement } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { ThemedToken } from \"shiki\";\n\nexport type Props = {\n /**\n * The source code string to highlight.\n */\n readonly code: string;\n\n /**\n * Set of line numbers to visually emphasize.\n */\n readonly highlightLines?: ReadonlySet<number>;\n\n /**\n * Programming language for syntax highlighting.\n * Falls back to plain text if the language is unknown.\n */\n readonly language?: string;\n\n /**\n * Whether to show line numbers in the gutter.\n * @default false\n */\n readonly showLineNumbers?: boolean;\n\n /**\n * Starting line number (useful for code excerpts).\n * @default 1\n */\n readonly startLine?: number;\n\n /**\n * Shiki theme name.\n * @default \"github-dark-default\"\n */\n readonly theme?: string;\n};\n\n/**\n * Render source code with syntax highlighting.\n *\n * ```tsx\n * &lt;Code code=\"const x = 42;\" language=\"typescript\" />\n * &lt;Code code={source} language=\"python\" showLineNumbers />\n * ```\n */\nexport default function Code({ code, highlightLines, language, showLineNumbers = false, startLine = 1, theme = \"github-dark-default\" }: Props): ReactElement {\n const [tokens, setTokens] = useState<ThemedToken[][] | null>(null);\n const cancelledRef = useRef(false);\n\n useEffect(() => {\n cancelledRef.current = false;\n\n const lang = language ? resolveLanguage(language) : \"text\";\n\n if (lang !== \"text\" && !isLanguageSupported(lang)) {\n // Unknown language — stay with plain rendering\n setTokens(null); // eslint-disable-line react-x/set-state-in-effect\n\n return;\n }\n\n void (async () => {\n try {\n const highlighter = await getHighlighter(lang === \"text\" ? [] : [lang], theme);\n const result = getCachedTokens(highlighter, code, lang, theme);\n\n if (!cancelledRef.current) {\n setTokens(result.tokens);\n }\n } catch {\n // Highlighting failed — fall back to plain text\n if (!cancelledRef.current) {\n setTokens(null);\n }\n }\n })();\n\n return () => {\n cancelledRef.current = true;\n };\n }, [code, language, theme]);\n\n const renderOptions: TokenRenderOptions = { highlightLines, showLineNumbers, startLine };\n\n // Highlighted rendering\n if (tokens) {\n return tokenLinesToElements(tokens, renderOptions);\n }\n\n // Plain text fallback (before highlighting resolves or for unsupported languages)\n const lines = code.split(\"\\n\");\n const gutterWidth = showLineNumbers ? String(startLine + lines.length - 1).length : 0;\n\n return (\n <Box flexDirection=\"column\">\n {lines.map((line, index) => {\n const lineNumber = startLine + index;\n const isHighlighted = highlightLines?.has(lineNumber) ?? false;\n\n return (\n <Box key={index}>\n {showLineNumbers\n ? (\n <Text color={isHighlighted ? \"yellow\" : undefined} dimColor={!isHighlighted}>\n {String(lineNumber).padStart(gutterWidth)}\n{\" \"}\n </Text>\n )\n : undefined}\n <Text>{line}</Text>\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { Code };\nexport type { Props as CodeProps };\n",
18
+ "path": "registry/ui/code.tsx",
19
+ "target": "components/ui/code.tsx",
20
+ "type": "registry:ui"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "collapsible",
4
+ "type": "registry:ui",
5
+ "title": "Collapsible",
6
+ "description": "Collapsible component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color when the header is focused.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus the collapsible on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Child content rendered when open.\n */\n readonly children: ReactNode;\n\n /**\n * Whether the section starts expanded.\n * @default false\n */\n readonly defaultOpen?: boolean;\n\n /**\n * Disable interaction.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Controlled open state.\n */\n readonly isOpen?: boolean;\n\n /**\n * Called when the user toggles the section.\n */\n readonly onToggle?: (isOpen: boolean) => void;\n\n /**\n * Header label.\n */\n readonly title: ReactNode;\n};\n\n/**\n * Focusable section that can be expanded or collapsed.\n */\nexport default function Collapsible({\n accentColor = \"blue\",\n autoFocus = false,\n children,\n defaultOpen = false,\n isDisabled = false,\n isOpen,\n onToggle,\n title,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const [internal, setInternal] = useState(defaultOpen);\n const open = isOpen ?? internal;\n\n useInput(\n useCallback(\n (input, key) => {\n if (key.return || input === \" \") {\n const next = !open;\n\n if (isOpen === undefined) {\n setInternal(next);\n }\n\n onToggle?.(next);\n }\n },\n [open, isOpen, onToggle],\n ),\n { isActive: !isDisabled && isFocused },\n );\n\n return (\n <Box flexDirection=\"column\">\n <Box>\n <Text color={isFocused ? accentColor : undefined} dimColor={isDisabled}>\n {open ? \"▼ \" : \"▶ \"}\n </Text>\n <Text bold={isFocused} color={isFocused ? accentColor : undefined} dimColor={isDisabled}>\n {title}\n </Text>\n </Box>\n {open\n ? (\n <Box flexDirection=\"column\" marginLeft={2}>\n {children}\n </Box>\n )\n : undefined}\n </Box>\n );\n}\n\nexport { Collapsible };\nexport type { Props as CollapsibleProps };\n",
19
+ "path": "registry/ui/collapsible.tsx",
20
+ "target": "components/ui/collapsible.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "color-picker",
4
+ "type": "registry:ui",
5
+ "title": "Color Picker",
6
+ "description": "Color Picker component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\ntype Color = LiteralUnion<AnsiColors, string>;\n\nconst DEFAULT_PALETTE: ReadonlyArray<Color> = [\n \"black\",\n \"red\",\n \"green\",\n \"yellow\",\n \"blue\",\n \"magenta\",\n \"cyan\",\n \"white\",\n \"gray\",\n \"redBright\",\n \"greenBright\",\n \"yellowBright\",\n \"blueBright\",\n \"magentaBright\",\n \"cyanBright\",\n \"whiteBright\",\n];\n\nexport type Props = {\n /**\n * Accent color for the focused-cell outline.\n * @default \"blue\"\n */\n readonly accentColor?: Color;\n\n /**\n * Auto-focus on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Number of swatches per row.\n * @default 8\n */\n readonly columns?: number;\n\n /**\n * Selected color when uncontrolled.\n */\n readonly defaultValue?: Color;\n\n /**\n * Disable input and dim the grid.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Fires when the highlighted swatch changes.\n */\n readonly onChange?: (color: Color) => void;\n\n /**\n * Fires on Enter with the highlighted swatch.\n */\n readonly onSubmit?: (color: Color) => void;\n\n /**\n * Colors to offer. Defaults to the 16 standard ANSI colors.\n */\n readonly palette?: ReadonlyArray<Color>;\n\n /**\n * Render the highlighted color's name beneath the grid.\n * @default true\n */\n readonly showName?: boolean;\n\n /**\n * The two-cell glyph filled with each palette color (default `\"██\"`).\n */\n readonly swatch?: string;\n\n /**\n * Controlled selected color. When provided, `defaultValue` is ignored.\n */\n readonly value?: Color;\n};\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\n/**\n * A grid of color swatches. Arrow keys move the highlight, Enter selects. The\n * highlighted swatch's name is shown below the grid by default.\n */\nexport default function ColorPicker({\n accentColor = \"blue\",\n autoFocus = false,\n columns = 8,\n defaultValue,\n isDisabled = false,\n onChange,\n onSubmit,\n palette = DEFAULT_PALETTE,\n showName = true,\n swatch = \"██\",\n value: controlledValue,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const isControlled = controlledValue !== undefined;\n // Guard against columns <= 0, which would never advance the row loop.\n const columnCount = Math.max(1, columns);\n\n const startIndex = Math.max(0, palette.indexOf((controlledValue ?? defaultValue) as Color));\n const [cursor, setCursor] = useState(startIndex);\n\n const effectiveCursor = isControlled ? Math.max(0, palette.indexOf(controlledValue)) : cursor;\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n const move = useCallback(\n (delta: number) => {\n if (palette.length === 0) {\n return;\n }\n\n const next = wrap(effectiveCursor + delta, palette.length);\n\n if (!isControlled) {\n setCursor(next);\n }\n\n onChangeRef.current?.(palette[next]!);\n },\n [effectiveCursor, isControlled, palette],\n );\n\n const inputHandler = useCallback(\n (_input: string, key: { downArrow: boolean; leftArrow: boolean; return: boolean; rightArrow: boolean; upArrow: boolean }) => {\n if (key.leftArrow) {\n move(-1);\n } else if (key.rightArrow) {\n move(1);\n } else if (key.upArrow) {\n move(-columnCount);\n } else if (key.downArrow) {\n move(columnCount);\n } else if (key.return) {\n onSubmit?.(palette[effectiveCursor]!);\n }\n },\n [columnCount, effectiveCursor, move, onSubmit, palette],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n const rows: Color[][] = [];\n\n for (let index = 0; index < palette.length; index += columnCount) {\n rows.push(palette.slice(index, index + columnCount));\n }\n\n return (\n <Box flexDirection=\"column\">\n {rows.map((row, rowIndex) => (\n // eslint-disable-next-line react-x/no-array-index-key -- row index is stable for the render\n <Box key={rowIndex}>\n {row.map((color, columnIndex) => {\n const index = rowIndex * columnCount + columnIndex;\n const isActive = isFocused && index === effectiveCursor;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- cell index is stable for the render\n <Box key={columnIndex}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"[\" : \" \"}</Text>\n <Text color={color} dimColor={isDisabled}>\n {swatch}\n </Text>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"]\" : \" \"}</Text>\n </Box>\n );\n })}\n </Box>\n ))}\n {showName\n ? (\n <Box marginTop={1}>\n <Text dimColor>{String(palette[effectiveCursor] ?? \"\")}</Text>\n </Box>\n )\n : undefined}\n </Box>\n );\n}\n\nexport { ColorPicker };\nexport type { Props as ColorPickerProps };\n",
19
+ "path": "registry/ui/color-picker.tsx",
20
+ "target": "components/ui/color-picker.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "command-block",
4
+ "type": "registry:ui",
5
+ "title": "Command Block",
6
+ "description": "Command Block component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [
16
+ "https://visulima.com/r/spinner.json"
17
+ ],
18
+ "files": [
19
+ {
20
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport Spinner from \"@/components/ui/spinner\";\n\nexport type CommandStatus = \"error\" | \"idle\" | \"running\" | \"success\";\n\nexport type Props = {\n /**\n * Shell command rendered in the header.\n */\n readonly command: string;\n\n /**\n * Optional working directory shown after the command name.\n */\n readonly cwd?: string;\n\n /**\n * Duration in ms; shown when finished.\n */\n readonly durationMs?: number;\n\n /**\n * Exit code of the completed command.\n */\n readonly exitCode?: number;\n\n /**\n * Max output rows rendered before truncation. Use `Infinity` to disable.\n */\n readonly maxOutputRows?: number;\n\n /**\n * Command output. Either a string (rendered as-is) or arbitrary children.\n */\n readonly output?: ReactNode;\n\n /**\n * Lifecycle state. Controls the header icon and color.\n * @default \"idle\"\n */\n readonly status?: CommandStatus;\n};\n\nconst STATUS_COLOR: Record<CommandStatus, LiteralUnion<AnsiColors, string>> = {\n error: \"red\",\n idle: \"gray\",\n running: \"cyan\",\n success: \"green\",\n};\n\nconst formatDuration = (ms: number): string => {\n if (ms < 1000) {\n return `${Math.round(ms)}ms`;\n }\n\n return `${(ms / 1000).toFixed(2)}s`;\n};\n\n/**\n * Resolve the header icon for a given status. `running` returns a live\n * Spinner; every other value maps to a static glyph.\n */\n// eslint-disable-next-line sonarjs/function-return-type -- legitimate union return: spinner element vs static glyph string\nconst resolveStatusIcon = (status: CommandStatus): ReactNode => {\n if (status === \"running\") {\n return <Spinner type=\"dots\" />;\n }\n\n const ICONS: Record<Exclude<CommandStatus, \"running\">, string> = {\n error: \"✖\",\n idle: \"$\",\n success: \"✔\",\n };\n\n return ICONS[status];\n};\n\nconst truncateOutput = (output: string, maxRows: number): string => {\n if (!Number.isFinite(maxRows)) {\n return output;\n }\n\n const rows = output.split(\"\\n\");\n\n if (rows.length <= maxRows) {\n return output;\n }\n\n const kept = rows.slice(0, maxRows).join(\"\\n\");\n const hidden = rows.length - maxRows;\n\n return `${kept}\\n… ${hidden} more line${hidden === 1 ? \"\" : \"s\"}`;\n};\n\n/**\n * Renders a shell command with its output and a status header. Designed for\n * coding-agent UIs that stream terminal operations.\n */\nexport default function CommandBlock({ command, cwd, durationMs, exitCode, maxOutputRows = 12, output, status = \"idle\" }: Props): ReactElement {\n const color = STATUS_COLOR[status];\n const statusIcon = resolveStatusIcon(status);\n\n const trailing: ReactNode[] = [];\n\n if (durationMs !== undefined) {\n trailing.push(\n <Text dimColor key=\"duration\">\n {\" \"}\n {formatDuration(durationMs)}\n </Text>,\n );\n }\n\n if (exitCode !== undefined && status !== \"running\") {\n trailing.push(\n <Text color={exitCode === 0 ? \"green\" : \"red\"} key=\"exit\">\n {\" \"}\n exit\n{\" \"}\n{exitCode}\n </Text>,\n );\n }\n\n return (\n <Box borderColor={color} borderStyle=\"round\" flexDirection=\"column\" paddingX={1}>\n <Box>\n <Text color={color}>{statusIcon}</Text>\n <Text> </Text>\n <Box flexGrow={1} flexShrink={1} minWidth={0}>\n <Text bold wrap=\"truncate-end\">\n {command}\n </Text>\n </Box>\n {cwd === undefined\n ? undefined\n : (\n<Text dimColor>\n{\" \"}\n@\n{cwd}\n</Text>\n )}\n {trailing.length === 0 ? undefined : <Box flexShrink={0}>{trailing}</Box>}\n </Box>\n {output === undefined\n ? undefined\n : (\n <Box flexDirection=\"column\" marginTop={1}>\n {typeof output === \"string\" ? <Text>{truncateOutput(output, maxOutputRows)}</Text> : output}\n </Box>\n )}\n </Box>\n );\n}\n\nexport { CommandBlock };\nexport type { Props as CommandBlockProps };\n",
21
+ "path": "registry/ui/command-block.tsx",
22
+ "target": "components/ui/command-block.tsx",
23
+ "type": "registry:ui"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "command-palette",
4
+ "type": "registry:ui",
5
+ "title": "Command Palette",
6
+ "description": "Command Palette component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport { isInsertableInput } from \"@visulima/tui/input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type CommandEntry = {\n /**\n * Optional description rendered under the label.\n */\n readonly description?: string;\n\n /**\n * Optional hotkey rendered on the right.\n */\n readonly hotkey?: string;\n\n /**\n * Optional icon rendered before the label.\n */\n readonly icon?: ReactNode;\n\n /**\n * Stable identifier returned by `onSelect`.\n */\n readonly id: string;\n\n /**\n * Optional keywords to boost matching (not displayed).\n */\n readonly keywords?: ReadonlyArray<string>;\n\n /**\n * Label shown in the list (and used as the primary match target).\n */\n readonly label: string;\n};\n\nexport type Props = {\n /**\n * Accent color for the border and focused row.\n * @default \"cyan\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus the palette on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Available commands to search.\n */\n readonly commands: ReadonlyArray<CommandEntry>;\n\n /**\n * Empty-state text when nothing matches.\n * @default \"No results\"\n */\n readonly emptyText?: string;\n\n /**\n * Max rows rendered in the list.\n * @default 8\n */\n readonly limit?: number;\n\n /**\n * Called when the user presses Escape.\n */\n readonly onCancel?: () => void;\n\n /**\n * Called when the user picks a command with Enter.\n */\n readonly onSelect: (id: string, query: string) => void;\n\n /**\n * Placeholder inside the search field.\n * @default \"Type a command or search...\"\n */\n readonly placeholder?: string;\n};\n\n/**\n * Rank candidates by a simple subsequence scoring algorithm. Higher is better.\n */\nconst scoreCommand = (query: string, command: CommandEntry): number => {\n if (query.length === 0) {\n return 1;\n }\n\n const haystacks = [command.label, command.description ?? \"\", ...command.keywords ?? []]\n .map((text) => text.toLowerCase())\n .filter((text) => text.length > 0);\n\n const needle = query.toLowerCase();\n let best = 0;\n\n for (const haystack of haystacks) {\n if (haystack.includes(needle)) {\n // Direct substring match: the earlier the match, the better.\n const index = haystack.indexOf(needle);\n\n best = Math.max(best, 100 - index);\n\n continue;\n }\n\n // Fallback: subsequence match. Count consecutive chars.\n let haystackIndex = 0;\n let matched = 0;\n\n for (const char of needle) {\n const found = haystack.indexOf(char, haystackIndex);\n\n if (found === -1) {\n matched = 0;\n\n break;\n }\n\n matched += 1;\n haystackIndex = found + 1;\n }\n\n if (matched === needle.length) {\n best = Math.max(best, 20);\n }\n }\n\n return best;\n};\n\n/**\n * Ctrl-P style fuzzy command finder. Features a search input, ranked results,\n * keyboard navigation, and Esc to cancel.\n */\nexport default function CommandPalette({\n accentColor = \"cyan\",\n autoFocus = true,\n commands,\n emptyText = \"No results\",\n limit = 8,\n onCancel,\n onSelect,\n placeholder = \"Type a command or search...\",\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus });\n const [query, setQuery] = useState(\"\");\n const [focusedIndex, setFocusedIndex] = useState(0);\n const focusedIndexRef = useRef(focusedIndex);\n\n focusedIndexRef.current = focusedIndex;\n\n const filtered = useMemo(() => {\n const scored = commands\n .map((command) => {\n return { command, score: scoreCommand(query, command) };\n })\n .filter(({ score }) => score > 0);\n\n scored.sort((a, b) => b.score - a.score);\n\n return scored.slice(0, limit).map(({ command }) => command);\n }, [commands, query, limit]);\n\n // Keep `focusedIndex` inside the live range. This handles shrinking\n // results (typing narrows the list) and external changes to `commands`\n // that would otherwise leave focus pointing past the last row.\n useEffect(() => {\n const maxIndex = Math.max(0, filtered.length - 1);\n\n if (focusedIndexRef.current > maxIndex) {\n focusedIndexRef.current = maxIndex;\n setFocusedIndex(maxIndex);\n }\n }, [filtered]);\n\n useInput(\n useCallback(\n (input, key) => {\n if (!isFocused) {\n return;\n }\n\n if (key.escape) {\n onCancel?.();\n\n return;\n }\n\n if (key.downArrow) {\n if (filtered.length === 0) {\n return;\n }\n\n setFocusedIndex((previous) => Math.min(filtered.length - 1, previous + 1));\n\n return;\n }\n\n if (key.upArrow) {\n setFocusedIndex((previous) => Math.max(0, previous - 1));\n\n return;\n }\n\n if (key.return) {\n const target = filtered[focusedIndexRef.current];\n\n if (target) {\n onSelect(target.id, query);\n }\n\n return;\n }\n\n if (key.backspace || key.delete) {\n setQuery((previous) => previous.slice(0, -1));\n setFocusedIndex(0);\n\n return;\n }\n\n if (isInsertableInput(input, key)) {\n setQuery((previous) => previous + input);\n setFocusedIndex(0);\n }\n },\n [isFocused, filtered, query, onSelect, onCancel],\n ),\n { isActive: isFocused },\n );\n\n return (\n <Box borderColor={accentColor} borderStyle=\"round\" flexDirection=\"column\" paddingX={1}>\n <Box>\n <Text color={accentColor}>❯</Text>\n <Text>\n{\" \"}\n{query.length === 0 ? <Text dimColor>{placeholder}</Text> : query}\n </Text>\n <Text inverse> </Text>\n </Box>\n <Box marginTop={1}>\n <Text dimColor>\n {filtered.length}\n {\" of \"}\n {commands.length}\n </Text>\n </Box>\n <Box flexDirection=\"column\" marginTop={1}>\n {filtered.length === 0\n ? (\n <Text dimColor>{emptyText}</Text>\n )\n : filtered.map((command, index) => {\n const isRowFocused = index === focusedIndex;\n const color = isRowFocused ? accentColor : undefined;\n\n return (\n <Box flexDirection=\"column\" key={command.id}>\n <Box>\n <Text color={color}>{isRowFocused ? \"▸ \" : \" \"}</Text>\n {command.icon === undefined\n ? undefined\n : (\n<Text color={color}>\n{command.icon}\n{\" \"}\n</Text>\n )}\n <Box flexGrow={1} flexShrink={1} minWidth={0}>\n <Text bold={isRowFocused} color={color} wrap=\"truncate-end\">\n {command.label}\n </Text>\n </Box>\n {command.hotkey === undefined\n ? undefined\n : (\n <Box flexShrink={0}>\n <Text dimColor>\n{\" \"}\n{command.hotkey}\n </Text>\n </Box>\n )}\n </Box>\n {command.description === undefined\n ? undefined\n : (\n <Box marginLeft={2}>\n <Text dimColor wrap=\"truncate-end\">\n {command.description}\n </Text>\n </Box>\n )}\n </Box>\n );\n })}\n </Box>\n </Box>\n );\n}\n\nexport { CommandPalette };\nexport type { Props as CommandPaletteProps };\n",
19
+ "path": "registry/ui/command-palette.tsx",
20
+ "target": "components/ui/command-palette.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "confirm-dialog",
4
+ "type": "registry:ui",
5
+ "title": "Confirm Dialog",
6
+ "description": "Confirm Dialog component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type ConfirmTone = \"danger\" | \"info\" | \"warning\";\n\nexport type Props = {\n /**\n * Auto-focus the dialog on mount. Set `false` if a parent already owns\n * focus and routes input here.\n * @default true\n */\n readonly autoFocus?: boolean;\n\n /**\n * Label for the cancel button.\n * @default \"Cancel\"\n */\n readonly cancelLabel?: string;\n\n /**\n * Body content (rendered between title and buttons).\n */\n readonly children?: ReactNode;\n\n /**\n * Label for the confirm button.\n * @default \"Confirm\"\n */\n readonly confirmLabel?: string;\n\n /**\n * Default button when opening the dialog.\n * @default \"confirm\"\n */\n readonly defaultFocus?: \"cancel\" | \"confirm\";\n\n /**\n * Disable input.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Fired when the user declines.\n */\n readonly onCancel: () => void;\n\n /**\n * Fired when the user confirms.\n */\n readonly onConfirm: () => void;\n\n /**\n * Heading rendered at the top of the dialog.\n */\n readonly title?: string;\n\n /**\n * Controls the border color.\n * @default \"info\"\n */\n readonly tone?: ConfirmTone;\n\n /**\n * Dialog width.\n */\n readonly width?: number;\n};\n\nconst TONE_COLOR: Record<ConfirmTone, LiteralUnion<AnsiColors, string>> = {\n danger: \"red\",\n info: \"blue\",\n warning: \"yellow\",\n};\n\ntype ButtonLabelProps = {\n readonly color: LiteralUnion<AnsiColors, string>;\n readonly isFocused: boolean;\n readonly label: string;\n};\n\n/**\n * Pill-shaped button rendered as the dialog's primary / secondary action.\n * Lives at module scope so it isn't recreated on every dialog render.\n */\nconst ButtonLabel = ({ color, isFocused, label }: ButtonLabelProps): ReactElement => (\n <Box backgroundColor={isFocused ? color : undefined} borderColor={isFocused ? color : undefined} borderStyle=\"round\" paddingX={1}>\n <Text bold={isFocused} color={isFocused ? \"black\" : undefined}>\n {label}\n </Text>\n </Box>\n);\n\n/**\n * Full-width modal-style confirm/cancel prompt. Unlike `ConfirmInput`, this\n * renders a visible two-button UI: ← / → to toggle focus, Enter to activate,\n * y/n as shortcuts, Esc to cancel.\n * @returns A bordered `ReactElement` with optional title, body, and two\n * buttons (cancel / confirm).\n */\nexport default function ConfirmDialog({\n autoFocus = true,\n cancelLabel = \"Cancel\",\n children,\n confirmLabel = \"Confirm\",\n defaultFocus = \"confirm\",\n isDisabled = false,\n onCancel,\n onConfirm,\n title,\n tone = \"info\",\n width,\n}: Props): ReactElement {\n const [focus, setFocus] = useState<\"cancel\" | \"confirm\">(defaultFocus);\n const color = TONE_COLOR[tone];\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n\n useInput(\n useCallback(\n (input, key) => {\n if (key.leftArrow || key.rightArrow || input === \"h\" || input === \"l\") {\n setFocus(focus === \"confirm\" ? \"cancel\" : \"confirm\");\n\n return;\n }\n\n if (key.return) {\n if (focus === \"confirm\") {\n onConfirm();\n } else {\n onCancel();\n }\n\n return;\n }\n\n if (key.escape || input === \"n\") {\n onCancel();\n\n return;\n }\n\n if (input === \"y\") {\n onConfirm();\n }\n },\n [focus, onConfirm, onCancel],\n ),\n { isActive: !isDisabled && isFocused },\n );\n\n return (\n <Box borderColor={color} borderStyle=\"round\" flexDirection=\"column\" paddingX={1} paddingY={1} width={width}>\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {title === undefined ? undefined : (\n <Box marginBottom={1}>\n <Text bold color={color}>\n {title}\n </Text>\n </Box>\n )}\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {children === undefined ? undefined : (\n <Box flexDirection=\"column\" marginBottom={1}>\n {typeof children === \"string\" ? <Text>{children}</Text> : children}\n </Box>\n )}\n <Box gap={2} justifyContent=\"flex-end\">\n <ButtonLabel color={color} isFocused={focus === \"cancel\"} label={cancelLabel} />\n <ButtonLabel color={color} isFocused={focus === \"confirm\"} label={confirmLabel} />\n </Box>\n </Box>\n );\n}\n\nexport { ConfirmDialog };\nexport type { Props as ConfirmDialogProps };\n",
19
+ "path": "registry/ui/confirm-dialog.tsx",
20
+ "target": "components/ui/confirm-dialog.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "confirm-input",
4
+ "type": "registry:ui",
5
+ "title": "Confirm Input",
6
+ "description": "Confirm Input component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Confirm input component for Ink.\n *\n * Inspired by ink-ui ConfirmInput by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-ui\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useRef } from \"react\";\n\nexport type Props = {\n /**\n * The default action when pressing Enter without typing Y or N.\n * @default \"confirm\"\n */\n readonly defaultChoice?: \"cancel\" | \"confirm\";\n\n /**\n * Whether the input is disabled.\n * @default false\n */\n readonly isDisabled?: boolean;\n\n /**\n * Called when the user cancels (presses N).\n */\n readonly onCancel: () => void;\n\n /**\n * Called when the user confirms (presses Y).\n */\n readonly onConfirm: () => void;\n\n /**\n * Whether pressing Enter submits the default choice.\n * @default true\n */\n readonly submitOnEnter?: boolean;\n};\n\n/**\n * Renders a Y/N confirmation prompt. Shows \"Y/n\" when default is confirm,\n * or \"y/N\" when default is cancel.\n */\nexport default function ConfirmInput({ defaultChoice = \"confirm\", isDisabled = false, onCancel, onConfirm, submitOnEnter = true }: Props): ReactElement {\n const onConfirmRef = useRef(onConfirm);\n\n onConfirmRef.current = onConfirm;\n const onCancelRef = useRef(onCancel);\n\n onCancelRef.current = onCancel;\n\n useInput(\n useCallback(\n (input, key) => {\n if (input.toLowerCase() === \"y\") {\n onConfirmRef.current();\n\n return;\n }\n\n if (input.toLowerCase() === \"n\") {\n onCancelRef.current();\n\n return;\n }\n\n if (key.return && submitOnEnter) {\n if (defaultChoice === \"confirm\") {\n onConfirmRef.current();\n } else {\n onCancelRef.current();\n }\n }\n },\n [submitOnEnter, defaultChoice],\n ),\n { isActive: !isDisabled },\n );\n\n const yesText = defaultChoice === \"confirm\" ? \"Y\" : \"y\";\n const noText = defaultChoice === \"cancel\" ? \"N\" : \"n\";\n\n return (\n <Box>\n <Text dimColor={isDisabled}>\n {/* eslint-disable-next-line @stylistic/jsx-one-expression-per-line */}\n {yesText}/{noText}\n </Text>\n </Box>\n );\n}\n\nexport { ConfirmInput };\nexport type { Props as ConfirmInputProps };\n",
17
+ "path": "registry/ui/confirm-input.tsx",
18
+ "target": "components/ui/confirm-input.tsx",
19
+ "type": "registry:ui"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "console-overlay",
4
+ "type": "registry:ui",
5
+ "title": "Console Overlay",
6
+ "description": "Console Overlay component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "/* eslint-disable react/function-component-definition, sonarjs/no-nested-conditional */\n\n/**\n * Console overlay component for Ink.\n *\n * Captures console.log/warn/error/debug output and displays it\n * in a dockable panel within the TUI, similar to browser devtools.\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ConsoleLevel } from \"@visulima/tui/hooks/use-console-capture\";\nimport useConsoleCapture from \"@visulima/tui/hooks/use-console-capture\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\n\nexport type ConsoleOverlayDock = \"bottom\" | \"top\";\n\nexport type Props = {\n /**\n * Dock position within the parent container.\n * @default \"bottom\"\n */\n readonly dock?: ConsoleOverlayDock;\n\n /**\n * Which console levels to capture.\n * @default [\"log\", \"info\", \"warn\", \"error\", \"debug\"]\n */\n readonly filter?: ReadonlyArray<ConsoleLevel>;\n\n /**\n * Visible height in rows.\n * @default 8\n */\n readonly height?: number;\n\n /**\n * Maximum entries to keep in the buffer.\n * @default 200\n */\n readonly maxEntries?: number;\n\n /**\n * Show the log level label.\n * @default true\n */\n readonly showLevel?: boolean;\n\n /**\n * Show timestamps on each entry.\n * @default true\n */\n readonly showTimestamp?: boolean;\n};\n\nconst LEVEL_COLORS: Record<ConsoleLevel, string> = {\n debug: \"gray\",\n error: \"red\",\n info: \"blue\",\n log: \"white\",\n warn: \"yellow\",\n};\n\nconst LEVEL_LABELS: Record<ConsoleLevel, string> = {\n debug: \"DBG\",\n error: \"ERR\",\n info: \"INF\",\n log: \"LOG\",\n warn: \"WRN\",\n};\n\nconst formatTimestamp = (ts: number): string => {\n const date = new Date(ts);\n const hours = String(date.getHours()).padStart(2, \"0\");\n const minutes = String(date.getMinutes()).padStart(2, \"0\");\n const seconds = String(date.getSeconds()).padStart(2, \"0\");\n\n return `${hours}:${minutes}:${seconds}`;\n};\n\n/**\n * A dockable console overlay that captures and displays console output.\n *\n * ```tsx\n * &lt;Box flexDirection=\"column\" height=\"100%\">\n * &lt;Box flexGrow={1}>{mainContent}&lt;/Box>\n * &lt;ConsoleOverlay dock=\"bottom\" height={6} />\n * &lt;/Box>\n * ```\n */\nexport default function ConsoleOverlay({ dock = \"bottom\", filter, height = 8, maxEntries = 200, showLevel = true, showTimestamp = true }: Props): ReactElement {\n const { entries } = useConsoleCapture({ filter, maxEntries });\n\n // Show the last `height` entries (auto-scroll to bottom)\n const visibleEntries = useMemo(() => entries.slice(-height), [entries, height]);\n\n return (\n <Box\n borderBottom={dock === \"top\"}\n borderBottomColor=\"gray\"\n borderTop={dock === \"bottom\"}\n borderTopColor=\"gray\"\n flexDirection=\"column\"\n height={height + 1}\n >\n {visibleEntries.length === 0\n ? (\n <Text dimColor>Console output will appear here...</Text>\n )\n : visibleEntries.map((entry) => (\n <Box key={entry.id}>\n {showTimestamp\n ? (\n<Text dimColor>\n[\n{formatTimestamp(entry.timestamp)}\n]\n{\" \"}\n</Text>\n )\n : undefined}\n {showLevel\n ? (\n<Text color={LEVEL_COLORS[entry.level]}>\n{LEVEL_LABELS[entry.level]}\n{\" \"}\n</Text>\n )\n : undefined}\n <Text color={entry.level === \"error\" ? \"red\" : entry.level === \"warn\" ? \"yellow\" : undefined}>{entry.message}</Text>\n </Box>\n ))}\n </Box>\n );\n}\n\nexport { ConsoleOverlay };\nexport type { Props as ConsoleOverlayProps };\n",
17
+ "path": "registry/ui/console-overlay.tsx",
18
+ "target": "components/ui/console-overlay.tsx",
19
+ "type": "registry:ui"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "content-switcher",
4
+ "type": "registry:ui",
5
+ "title": "Content Switcher",
6
+ "description": "Content Switcher component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type ContentSwitcherOption = {\n readonly content: ReactNode;\n readonly icon?: ReactNode;\n readonly id: string;\n readonly label: string;\n};\n\nexport type Props = {\n /**\n * Accent color for the selected segment.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus the switcher on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Initial selected id (uncontrolled).\n */\n readonly defaultValue?: string;\n\n /**\n * Disable input.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Called when the selection changes.\n */\n readonly onChange?: (id: string) => void;\n\n /**\n * Segments to toggle between.\n */\n readonly options: ReadonlyArray<ContentSwitcherOption>;\n\n /**\n * Controlled selected id.\n */\n readonly value?: string;\n};\n\n/**\n * Segmented control that flips between mutually exclusive content. More\n * compact than `Tabs`: the selected segment is highlighted as a pill, and\n * only the active segment's content is rendered.\n * @returns A `ReactElement` with a segmented header row and the active\n * panel's content beneath it.\n */\nexport default function ContentSwitcher({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue,\n isDisabled = false,\n onChange,\n options,\n value,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const initialId = defaultValue ?? options[0]?.id;\n const [internal, setInternal] = useState(initialId);\n const current = value ?? internal;\n\n const currentIndex = useMemo(() => {\n if (current === undefined) {\n return 0;\n }\n\n const index = options.findIndex((option) => option.id === current);\n\n return Math.max(index, 0);\n }, [options, current]);\n\n const commit = useCallback(\n (index: number) => {\n const target = options[index];\n\n if (!target) {\n return;\n }\n\n const currentId = value ?? internal;\n\n if (target.id === currentId) {\n return;\n }\n\n if (value === undefined) {\n setInternal(target.id);\n }\n\n onChange?.(target.id);\n },\n [options, value, internal, onChange],\n );\n\n useInput(\n useCallback(\n (input, key) => {\n if (!isFocused) {\n return;\n }\n\n if (key.leftArrow || input === \"h\") {\n commit(Math.max(0, currentIndex - 1));\n } else if (key.rightArrow || input === \"l\") {\n commit(Math.min(options.length - 1, currentIndex + 1));\n }\n },\n [isFocused, currentIndex, options.length, commit],\n ),\n { isActive: !isDisabled && isFocused },\n );\n\n const activeOption = options[currentIndex];\n\n return (\n <Box flexDirection=\"column\">\n <Box>\n {options.map((option, index) => {\n const isActive = index === currentIndex;\n const background = isActive ? accentColor : undefined;\n const color = isActive ? \"black\" : undefined;\n\n return (\n <Text backgroundColor={background} color={color} dimColor={isDisabled && !isActive} key={option.id}>\n {\" \"}\n {option.icon === undefined\n ? undefined\n : (\n<>\n{option.icon}\n{\" \"}\n</>\n )}\n {option.label}\n{\" \"}\n </Text>\n );\n })}\n </Box>\n {activeOption === undefined\n ? undefined\n : (\n <Box flexDirection=\"column\" marginTop={1}>\n {activeOption.content}\n </Box>\n )}\n </Box>\n );\n}\n\nexport { ContentSwitcher };\nexport type { Props as ContentSwitcherProps };\n",
19
+ "path": "registry/ui/content-switcher.tsx",
20
+ "target": "components/ui/content-switcher.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "controlled-scroll-view",
4
+ "type": "registry:ui",
5
+ "title": "Controlled Scroll View",
6
+ "description": "Controlled Scroll View component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [
14
+ "https://visulima.com/r/scroll.json"
15
+ ],
16
+ "files": [
17
+ {
18
+ "content": "export type { ControlledScrollViewProps, ControlledScrollViewRef } from \"@/components/ui/scroll/controlled-scroll-view\";\nexport { ControlledScrollView } from \"@/components/ui/scroll/controlled-scroll-view\";\n",
19
+ "path": "registry/ui/controlled-scroll-view.ts",
20
+ "target": "components/ui/controlled-scroll-view.ts",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "data-grid",
4
+ "type": "registry:ui",
5
+ "title": "Data Grid",
6
+ "description": "Data Grid component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type DataGridColumn<Row> = {\n readonly align?: \"left\" | \"right\";\n readonly header: string;\n readonly key: keyof Row & string;\n\n /**\n * Render a cell to a string. Defaults to `String(row[key])`.\n */\n readonly render?: (row: Row) => string;\n readonly width?: number;\n};\n\nexport type Props<Row extends Record<string, unknown>> = {\n /**\n * Accent color for the cursor row and active sort column.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus for keyboard navigation.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Column definitions in display order.\n */\n readonly columns: ReadonlyArray<DataGridColumn<Row>>;\n\n /**\n * The rows to display.\n */\n readonly data: ReadonlyArray<Row>;\n\n /**\n * Fires with the row under the cursor when Enter is pressed.\n */\n readonly onSelect?: (row: Row) => void;\n};\n\ntype SortState = { readonly direction: \"asc\" | \"desc\"; readonly key: string } | undefined;\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\nconst cellText = <Row extends Record<string, unknown>>(column: DataGridColumn<Row>, row: Row): string => {\n if (column.render) {\n return column.render(row);\n }\n\n return String(row[column.key] ?? \"\");\n};\n\nconst pad = (text: string, width: number, align: \"left\" | \"right\"): string => {\n if (text.length >= width) {\n return text.slice(0, width);\n }\n\n return align === \"right\" ? text.padStart(width) : text.padEnd(width);\n};\n\n/**\n * A navigable, sortable table. ↑/↓ move the row cursor, ←/→ move the column\n * cursor, `s` sorts by the cursor column (toggling direction), and Enter fires\n * `onSelect` with the current row. Column widths default to the widest cell.\n */\nexport default function DataGrid<Row extends Record<string, unknown>>({\n accentColor = \"blue\",\n autoFocus = false,\n columns,\n data,\n onSelect,\n}: Props<Row>): ReactElement {\n const { isFocused } = useFocus({ autoFocus });\n\n const [rowCursor, setRowCursor] = useState(0);\n const [columnCursor, setColumnCursor] = useState(0);\n const [sort, setSort] = useState<SortState>(undefined);\n\n const widths = useMemo(\n () =>\n columns.map((column) => {\n if (column.width !== undefined) {\n return column.width;\n }\n\n // Reserve two trailing cells for the \" ▲\"/\" ▼\" sort indicator so\n // it never truncates the header once the column becomes active.\n let max = column.header.length + 2;\n\n for (const row of data) {\n max = Math.max(max, cellText(column, row).length);\n }\n\n return max;\n }),\n [columns, data],\n );\n\n const sorted = useMemo(() => {\n if (sort === undefined) {\n return data;\n }\n\n const column = columns.find((candidate) => candidate.key === sort.key);\n\n if (column === undefined) {\n return data;\n }\n\n const factor = sort.direction === \"asc\" ? 1 : -1;\n\n return [...data].toSorted((a, b) => {\n const left = cellText(column, a);\n const right = cellText(column, b);\n const leftNumber = Number(left);\n const rightNumber = Number(right);\n const bothNumeric = left.length > 0 && right.length > 0 && !Number.isNaN(leftNumber) && !Number.isNaN(rightNumber);\n\n return factor * (bothNumeric ? leftNumber - rightNumber : left.localeCompare(right));\n });\n }, [columns, data, sort]);\n\n const inputHandler = useCallback(\n (input: string, key: { downArrow: boolean; leftArrow: boolean; return: boolean; rightArrow: boolean; upArrow: boolean }) => {\n if (key.upArrow) {\n setRowCursor((index) => wrap(index - 1, sorted.length));\n } else if (key.downArrow) {\n setRowCursor((index) => wrap(index + 1, sorted.length));\n } else if (key.leftArrow) {\n setColumnCursor((index) => wrap(index - 1, columns.length));\n } else if (key.rightArrow) {\n setColumnCursor((index) => wrap(index + 1, columns.length));\n } else if (input === \"s\") {\n const column = columns[columnCursor];\n\n if (column !== undefined) {\n setSort((current) => {\n if (current?.key === column.key && current.direction === \"asc\") {\n return { direction: \"desc\", key: column.key };\n }\n\n return { direction: \"asc\", key: column.key };\n });\n }\n } else if (key.return) {\n const row = sorted[rowCursor];\n\n if (row !== undefined) {\n onSelect?.(row);\n }\n }\n },\n [columnCursor, columns, onSelect, rowCursor, sorted],\n );\n\n useInput(inputHandler, { isActive: isFocused });\n\n const sortArrow = (columnKey: string): string => {\n if (sort?.key !== columnKey) {\n return \"\";\n }\n\n return sort.direction === \"asc\" ? \" ▲\" : \" ▼\";\n };\n\n return (\n <Box flexDirection=\"column\">\n <Box>\n <Text> </Text>\n {columns.map((column, index) => {\n const isSortColumn = isFocused && index === columnCursor;\n\n return (\n <Box key={column.key} marginRight={1}>\n <Text bold color={isSortColumn ? accentColor : undefined}>\n {pad(`${column.header}${sortArrow(column.key)}`, widths[index]!, column.align ?? \"left\")}\n </Text>\n </Box>\n );\n })}\n </Box>\n {sorted.map((row, rowIndex) => {\n const isActive = isFocused && rowIndex === rowCursor;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- row index is stable for the render\n <Box key={rowIndex}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"❯\" : \" \"}</Text>\n {columns.map((column, columnIndex) => (\n <Box key={column.key} marginRight={1}>\n <Text color={isActive ? accentColor : undefined} dimColor={!isActive}>\n {pad(cellText(column, row), widths[columnIndex]!, column.align ?? \"left\")}\n </Text>\n </Box>\n ))}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { DataGrid };\nexport type { Props as DataGridProps };\n",
19
+ "path": "registry/ui/data-grid.tsx",
20
+ "target": "components/ui/data-grid.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }