makersclaw 0.1.0 → 0.3.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 (478) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -1
  3. package/THIRD-PARTY-NOTICES.md +180 -0
  4. package/package.json +15 -8
  5. package/r/annotate.json +62 -0
  6. package/r/approval-request.json +52 -0
  7. package/r/audio-player.json +74 -0
  8. package/r/auth-form.json +55 -0
  9. package/r/board.json +55 -0
  10. package/r/calendar-view.json +105 -0
  11. package/r/cart.json +62 -0
  12. package/r/chart.json +83 -0
  13. package/r/chat.json +95 -0
  14. package/r/code-editor.json +94 -0
  15. package/r/comment-box.json +64 -0
  16. package/r/contact-section.json +53 -0
  17. package/r/core.json +341 -0
  18. package/r/csv-import.json +63 -0
  19. package/r/data-table.json +69 -0
  20. package/r/diagram.json +74 -0
  21. package/r/doc-editor.json +82 -0
  22. package/r/doc-page.json +61 -0
  23. package/r/email-render.json +73 -0
  24. package/r/faq.json +50 -0
  25. package/r/file-upload.json +54 -0
  26. package/r/flow-canvas.json +93 -0
  27. package/r/footer.json +52 -0
  28. package/r/gantt.json +70 -0
  29. package/r/job-panel.json +56 -0
  30. package/r/json-editor.json +66 -0
  31. package/r/map.json +89 -0
  32. package/r/menubar.json +52 -0
  33. package/r/mind-map.json +67 -0
  34. package/r/navigation-menu.json +52 -0
  35. package/r/order-detail.json +54 -0
  36. package/r/org-chart.json +66 -0
  37. package/r/page-builder.json +105 -0
  38. package/r/payment-methods.json +51 -0
  39. package/r/pdf-document.json +79 -0
  40. package/r/pdf-viewer.json +88 -0
  41. package/r/product-detail.json +76 -0
  42. package/r/product-grid.json +65 -0
  43. package/r/record-form.json +67 -0
  44. package/r/record-notes.json +55 -0
  45. package/r/registry.json +3612 -0
  46. package/r/search-dialog.json +51 -0
  47. package/r/settings-list.json +66 -0
  48. package/r/setup-checklist.json +51 -0
  49. package/r/share-dialog.json +52 -0
  50. package/r/sidebar.json +69 -0
  51. package/r/signature-pad.json +72 -0
  52. package/r/sketch-canvas.json +99 -0
  53. package/r/spreadsheet.json +90 -0
  54. package/r/stat-cards.json +66 -0
  55. package/r/task-list.json +63 -0
  56. package/r/task-model.json +98 -0
  57. package/r/testimonials.json +52 -0
  58. package/r/transaction-list.json +53 -0
  59. package/r/whiteboard.json +66 -0
  60. package/registry/blocks/annotate/README.md +179 -0
  61. package/registry/blocks/annotate/annotate-data.js +250 -0
  62. package/registry/blocks/annotate/annotate.jsx +646 -0
  63. package/registry/blocks/annotate/block.json +10 -0
  64. package/registry/blocks/annotate/demo.jsx +135 -0
  65. package/registry/blocks/annotate/index.js +15 -0
  66. package/registry/blocks/approval-request/README.md +116 -0
  67. package/registry/blocks/approval-request/approval-request.jsx +386 -0
  68. package/registry/blocks/approval-request/block.json +10 -0
  69. package/registry/blocks/approval-request/demo.jsx +127 -0
  70. package/registry/blocks/approval-request/index.js +4 -0
  71. package/registry/blocks/audio-player/README.md +167 -0
  72. package/registry/blocks/audio-player/audio-player.jsx +623 -0
  73. package/registry/blocks/audio-player/block.json +14 -0
  74. package/registry/blocks/audio-player/colors.js +109 -0
  75. package/registry/blocks/audio-player/demo.jsx +209 -0
  76. package/registry/blocks/audio-player/index.js +6 -0
  77. package/registry/blocks/audio-player/time.js +67 -0
  78. package/registry/blocks/audio-player/wavesurfer.js +9 -0
  79. package/registry/blocks/auth-form/README.md +126 -0
  80. package/registry/blocks/auth-form/auth-form.jsx +568 -0
  81. package/registry/blocks/auth-form/block.json +10 -0
  82. package/registry/blocks/auth-form/demo.jsx +131 -0
  83. package/registry/blocks/auth-form/index.js +3 -0
  84. package/registry/blocks/board/README.md +64 -13
  85. package/registry/blocks/board/block.json +2 -2
  86. package/registry/blocks/board/board.jsx +237 -94
  87. package/registry/blocks/board/demo.jsx +103 -24
  88. package/registry/blocks/calendar-view/README.md +180 -0
  89. package/registry/blocks/calendar-view/agenda.jsx +147 -0
  90. package/registry/blocks/calendar-view/block.json +76 -0
  91. package/registry/blocks/calendar-view/calendar-model.js +442 -0
  92. package/registry/blocks/calendar-view/calendar.jsx +627 -0
  93. package/registry/blocks/calendar-view/demo.jsx +195 -0
  94. package/registry/blocks/calendar-view/draft.jsx +49 -0
  95. package/registry/blocks/calendar-view/index.js +39 -0
  96. package/registry/blocks/calendar-view/month.jsx +346 -0
  97. package/registry/blocks/calendar-view/paint.js +69 -0
  98. package/registry/blocks/calendar-view/time-grid.jsx +527 -0
  99. package/registry/blocks/calendar-view/tray.jsx +87 -0
  100. package/registry/blocks/cart/README.md +146 -0
  101. package/registry/blocks/cart/block.json +10 -0
  102. package/registry/blocks/cart/cart.jsx +597 -0
  103. package/registry/blocks/cart/demo.jsx +98 -0
  104. package/registry/blocks/cart/index.js +4 -0
  105. package/registry/blocks/cart/money.js +28 -0
  106. package/registry/blocks/chart/README.md +316 -0
  107. package/registry/blocks/chart/block.json +10 -0
  108. package/registry/blocks/chart/cartesian.jsx +642 -0
  109. package/registry/blocks/chart/core.jsx +262 -0
  110. package/registry/blocks/chart/demo.jsx +357 -0
  111. package/registry/blocks/chart/heatmap.jsx +336 -0
  112. package/registry/blocks/chart/index.js +8 -0
  113. package/registry/blocks/chart/pie.jsx +245 -0
  114. package/registry/blocks/chart/sparkline.jsx +88 -0
  115. package/registry/blocks/chat/README.md +273 -0
  116. package/registry/blocks/chat/attachment.jsx +95 -0
  117. package/registry/blocks/chat/block.json +10 -0
  118. package/registry/blocks/chat/chat.jsx +132 -0
  119. package/registry/blocks/chat/composer.jsx +364 -0
  120. package/registry/blocks/chat/demo.jsx +578 -0
  121. package/registry/blocks/chat/header.jsx +34 -0
  122. package/registry/blocks/chat/index.js +9 -0
  123. package/registry/blocks/chat/message.jsx +450 -0
  124. package/registry/blocks/chat/scroller.jsx +247 -0
  125. package/registry/blocks/chat/text.jsx +104 -0
  126. package/registry/blocks/code-editor/README.md +210 -0
  127. package/registry/blocks/code-editor/block.json +22 -0
  128. package/registry/blocks/code-editor/code-editor.jsx +534 -0
  129. package/registry/blocks/code-editor/codemirror.js +38 -0
  130. package/registry/blocks/code-editor/demo.jsx +205 -0
  131. package/registry/blocks/code-editor/index.js +8 -0
  132. package/registry/blocks/code-editor/json.js +317 -0
  133. package/registry/blocks/code-editor/sql.js +136 -0
  134. package/registry/blocks/code-editor/text.js +101 -0
  135. package/registry/blocks/code-editor/theme.js +186 -0
  136. package/registry/blocks/comment-box/README.md +123 -0
  137. package/registry/blocks/comment-box/block.json +10 -0
  138. package/registry/blocks/comment-box/comment-body.jsx +217 -0
  139. package/registry/blocks/comment-box/comment-box.jsx +198 -0
  140. package/registry/blocks/comment-box/comment-time.jsx +77 -0
  141. package/registry/blocks/comment-box/demo.jsx +141 -0
  142. package/registry/blocks/comment-box/index.js +8 -0
  143. package/registry/blocks/contact-section/README.md +152 -0
  144. package/registry/blocks/contact-section/block.json +11 -0
  145. package/registry/blocks/contact-section/contact-section.jsx +509 -0
  146. package/registry/blocks/contact-section/demo.jsx +93 -0
  147. package/registry/blocks/contact-section/index.js +4 -0
  148. package/registry/blocks/csv-import/README.md +133 -0
  149. package/registry/blocks/csv-import/block.json +13 -0
  150. package/registry/blocks/csv-import/csv-import.jsx +479 -0
  151. package/registry/blocks/csv-import/demo.jsx +106 -0
  152. package/registry/blocks/csv-import/import.js +200 -0
  153. package/registry/blocks/csv-import/index.js +7 -0
  154. package/registry/blocks/data-table/README.md +386 -0
  155. package/registry/blocks/data-table/block.json +10 -0
  156. package/registry/blocks/data-table/data-table.jsx +1164 -0
  157. package/registry/blocks/data-table/demo.jsx +588 -0
  158. package/registry/blocks/data-table/index.js +5 -0
  159. package/registry/blocks/data-table/model.js +169 -0
  160. package/registry/blocks/diagram/README.md +108 -0
  161. package/registry/blocks/diagram/block.json +13 -0
  162. package/registry/blocks/diagram/demo.jsx +113 -0
  163. package/registry/blocks/diagram/diagram.jsx +481 -0
  164. package/registry/blocks/diagram/index.js +7 -0
  165. package/registry/blocks/diagram/mermaid.js +52 -0
  166. package/registry/blocks/diagram/palette.js +313 -0
  167. package/registry/blocks/diagram/source.js +98 -0
  168. package/registry/blocks/doc-editor/README.md +215 -0
  169. package/registry/blocks/doc-editor/block.json +15 -0
  170. package/registry/blocks/doc-editor/demo.jsx +225 -0
  171. package/registry/blocks/doc-editor/doc-editor.jsx +291 -0
  172. package/registry/blocks/doc-editor/doc.js +309 -0
  173. package/registry/blocks/doc-editor/index.js +7 -0
  174. package/registry/blocks/doc-editor/schema.jsx +56 -0
  175. package/registry/blocks/doc-editor/shadcn-classes.js +110 -0
  176. package/registry/blocks/doc-editor/theme.js +82 -0
  177. package/registry/blocks/doc-page/README.md +119 -0
  178. package/registry/blocks/doc-page/autosave.js +104 -0
  179. package/registry/blocks/doc-page/block.json +10 -0
  180. package/registry/blocks/doc-page/demo.jsx +137 -0
  181. package/registry/blocks/doc-page/doc-page.jsx +276 -0
  182. package/registry/blocks/doc-page/index.js +4 -0
  183. package/registry/blocks/email-render/README.md +151 -0
  184. package/registry/blocks/email-render/block.json +14 -0
  185. package/registry/blocks/email-render/demo.jsx +153 -0
  186. package/registry/blocks/email-render/doc-email.jsx +210 -0
  187. package/registry/blocks/email-render/email.jsx +221 -0
  188. package/registry/blocks/email-render/index.js +10 -0
  189. package/registry/blocks/email-render/render.js +50 -0
  190. package/registry/blocks/email-render/theme.js +215 -0
  191. package/registry/blocks/faq/README.md +101 -0
  192. package/registry/blocks/faq/block.json +11 -0
  193. package/registry/blocks/faq/demo.jsx +89 -0
  194. package/registry/blocks/faq/faq.jsx +220 -0
  195. package/registry/blocks/faq/index.js +4 -0
  196. package/registry/blocks/file-upload/README.md +138 -0
  197. package/registry/blocks/file-upload/block.json +10 -0
  198. package/registry/blocks/file-upload/demo.jsx +204 -0
  199. package/registry/blocks/file-upload/file-upload.jsx +635 -0
  200. package/registry/blocks/file-upload/index.js +4 -0
  201. package/registry/blocks/flow-canvas/README.md +420 -0
  202. package/registry/blocks/flow-canvas/block.json +13 -0
  203. package/registry/blocks/flow-canvas/board.js +819 -0
  204. package/registry/blocks/flow-canvas/chrome.jsx +649 -0
  205. package/registry/blocks/flow-canvas/context.jsx +35 -0
  206. package/registry/blocks/flow-canvas/demo.jsx +249 -0
  207. package/registry/blocks/flow-canvas/edges.jsx +107 -0
  208. package/registry/blocks/flow-canvas/flow-canvas.jsx +1329 -0
  209. package/registry/blocks/flow-canvas/index.js +41 -0
  210. package/registry/blocks/flow-canvas/nodes.jsx +392 -0
  211. package/registry/blocks/flow-canvas/theme.js +174 -0
  212. package/registry/blocks/footer/README.md +133 -0
  213. package/registry/blocks/footer/block.json +11 -0
  214. package/registry/blocks/footer/demo.jsx +119 -0
  215. package/registry/blocks/footer/footer.jsx +406 -0
  216. package/registry/blocks/footer/index.js +4 -0
  217. package/registry/blocks/gantt/README.md +220 -0
  218. package/registry/blocks/gantt/block.json +10 -0
  219. package/registry/blocks/gantt/demo.jsx +148 -0
  220. package/registry/blocks/gantt/gantt.jsx +923 -0
  221. package/registry/blocks/gantt/index.js +7 -0
  222. package/registry/blocks/gantt/rows.jsx +468 -0
  223. package/registry/blocks/gantt/schedule.js +278 -0
  224. package/registry/blocks/job-panel/README.md +86 -2
  225. package/registry/blocks/job-panel/block.json +3 -3
  226. package/registry/blocks/job-panel/demo.jsx +230 -3
  227. package/registry/blocks/job-panel/job-panel.jsx +169 -3
  228. package/registry/blocks/json-editor/README.md +133 -0
  229. package/registry/blocks/json-editor/block.json +10 -0
  230. package/registry/blocks/json-editor/demo.jsx +92 -0
  231. package/registry/blocks/json-editor/index.js +7 -0
  232. package/registry/blocks/json-editor/json-editor.jsx +305 -0
  233. package/registry/blocks/json-editor/json-tree.jsx +393 -0
  234. package/registry/blocks/json-editor/tree.js +225 -0
  235. package/registry/blocks/map/README.md +221 -0
  236. package/registry/blocks/map/block.json +14 -0
  237. package/registry/blocks/map/demo.jsx +201 -0
  238. package/registry/blocks/map/geo.js +198 -0
  239. package/registry/blocks/map/index.js +8 -0
  240. package/registry/blocks/map/list.jsx +128 -0
  241. package/registry/blocks/map/map-view.jsx +715 -0
  242. package/registry/blocks/map/maplibre.js +30 -0
  243. package/registry/blocks/map/marks.jsx +146 -0
  244. package/registry/blocks/map/theme.js +142 -0
  245. package/registry/blocks/menubar/README.md +109 -0
  246. package/registry/blocks/menubar/block.json +10 -0
  247. package/registry/blocks/menubar/demo.jsx +128 -0
  248. package/registry/blocks/menubar/index.js +4 -0
  249. package/registry/blocks/menubar/menubar.jsx +199 -0
  250. package/registry/blocks/mind-map/README.md +206 -0
  251. package/registry/blocks/mind-map/block.json +10 -0
  252. package/registry/blocks/mind-map/demo.jsx +103 -0
  253. package/registry/blocks/mind-map/index.js +31 -0
  254. package/registry/blocks/mind-map/mind-map.jsx +1125 -0
  255. package/registry/blocks/mind-map/topic.jsx +143 -0
  256. package/registry/blocks/mind-map/tree.js +645 -0
  257. package/registry/blocks/navigation-menu/README.md +124 -0
  258. package/registry/blocks/navigation-menu/block.json +10 -0
  259. package/registry/blocks/navigation-menu/demo.jsx +84 -0
  260. package/registry/blocks/navigation-menu/index.js +4 -0
  261. package/registry/blocks/navigation-menu/navigation-menu.jsx +448 -0
  262. package/registry/blocks/order-detail/README.md +109 -0
  263. package/registry/blocks/order-detail/block.json +10 -0
  264. package/registry/blocks/order-detail/demo.jsx +88 -0
  265. package/registry/blocks/order-detail/index.js +4 -0
  266. package/registry/blocks/order-detail/order-detail.jsx +354 -0
  267. package/registry/blocks/org-chart/README.md +145 -0
  268. package/registry/blocks/org-chart/block.json +10 -0
  269. package/registry/blocks/org-chart/demo.jsx +94 -0
  270. package/registry/blocks/org-chart/index.js +7 -0
  271. package/registry/blocks/org-chart/org-chart.jsx +631 -0
  272. package/registry/blocks/org-chart/org.js +245 -0
  273. package/registry/blocks/org-chart/person.jsx +96 -0
  274. package/registry/blocks/page-builder/README.md +257 -0
  275. package/registry/blocks/page-builder/block.json +14 -0
  276. package/registry/blocks/page-builder/demo.jsx +145 -0
  277. package/registry/blocks/page-builder/grapes.js +18 -0
  278. package/registry/blocks/page-builder/index.js +11 -0
  279. package/registry/blocks/page-builder/page-builder.jsx +704 -0
  280. package/registry/blocks/page-builder/page-view.jsx +33 -0
  281. package/registry/blocks/page-builder/page.js +118 -0
  282. package/registry/blocks/page-builder/panels.jsx +588 -0
  283. package/registry/blocks/page-builder/sections.js +209 -0
  284. package/registry/blocks/page-builder/theme.js +87 -0
  285. package/registry/blocks/page-builder/thumbs.jsx +189 -0
  286. package/registry/blocks/page-builder/tokens.js +329 -0
  287. package/registry/blocks/payment-methods/README.md +104 -0
  288. package/registry/blocks/payment-methods/block.json +10 -0
  289. package/registry/blocks/payment-methods/demo.jsx +71 -0
  290. package/registry/blocks/payment-methods/index.js +4 -0
  291. package/registry/blocks/payment-methods/payment-methods.jsx +302 -0
  292. package/registry/blocks/pdf-document/README.md +213 -0
  293. package/registry/blocks/pdf-document/block.json +13 -0
  294. package/registry/blocks/pdf-document/demo.jsx +136 -0
  295. package/registry/blocks/pdf-document/index.js +33 -0
  296. package/registry/blocks/pdf-document/money.js +83 -0
  297. package/registry/blocks/pdf-document/pdf-document.jsx +144 -0
  298. package/registry/blocks/pdf-document/primitives.jsx +271 -0
  299. package/registry/blocks/pdf-document/quote.jsx +179 -0
  300. package/registry/blocks/pdf-document/theme.js +193 -0
  301. package/registry/blocks/pdf-viewer/README.md +191 -0
  302. package/registry/blocks/pdf-viewer/block.json +14 -0
  303. package/registry/blocks/pdf-viewer/demo.jsx +252 -0
  304. package/registry/blocks/pdf-viewer/helpers.js +270 -0
  305. package/registry/blocks/pdf-viewer/index.js +8 -0
  306. package/registry/blocks/pdf-viewer/notes.jsx +195 -0
  307. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1368 -0
  308. package/registry/blocks/pdf-viewer/pdfjs.js +37 -0
  309. package/registry/blocks/pdf-viewer/ssr-shim-end.js +6 -0
  310. package/registry/blocks/pdf-viewer/ssr-shim.js +44 -0
  311. package/registry/blocks/product-detail/README.md +217 -0
  312. package/registry/blocks/product-detail/block.json +10 -0
  313. package/registry/blocks/product-detail/demo.jsx +233 -0
  314. package/registry/blocks/product-detail/index.js +7 -0
  315. package/registry/blocks/product-detail/price.jsx +115 -0
  316. package/registry/blocks/product-detail/product-detail.jsx +348 -0
  317. package/registry/blocks/product-detail/product-media.jsx +102 -0
  318. package/registry/blocks/product-detail/product-options.jsx +144 -0
  319. package/registry/blocks/product-grid/README.md +162 -0
  320. package/registry/blocks/product-grid/block.json +10 -0
  321. package/registry/blocks/product-grid/demo.jsx +255 -0
  322. package/registry/blocks/product-grid/index.js +5 -0
  323. package/registry/blocks/product-grid/product-card.jsx +232 -0
  324. package/registry/blocks/product-grid/product-grid.jsx +235 -0
  325. package/registry/blocks/record-form/README.md +196 -0
  326. package/registry/blocks/record-form/block.json +10 -0
  327. package/registry/blocks/record-form/demo.jsx +525 -0
  328. package/registry/blocks/record-form/index.js +4 -0
  329. package/registry/blocks/record-form/record-fields.jsx +457 -0
  330. package/registry/blocks/record-form/record-form.jsx +415 -0
  331. package/registry/blocks/record-form/rules.js +81 -0
  332. package/registry/blocks/record-notes/README.md +139 -0
  333. package/registry/blocks/record-notes/block.json +10 -0
  334. package/registry/blocks/record-notes/demo.jsx +179 -0
  335. package/registry/blocks/record-notes/index.js +4 -0
  336. package/registry/blocks/record-notes/record-notes.jsx +266 -0
  337. package/registry/blocks/search-dialog/README.md +108 -0
  338. package/registry/blocks/search-dialog/block.json +10 -0
  339. package/registry/blocks/search-dialog/demo.jsx +116 -0
  340. package/registry/blocks/search-dialog/index.js +3 -0
  341. package/registry/blocks/search-dialog/search-dialog.jsx +235 -0
  342. package/registry/blocks/settings-list/README.md +171 -0
  343. package/registry/blocks/settings-list/block.json +10 -0
  344. package/registry/blocks/settings-list/demo.jsx +333 -0
  345. package/registry/blocks/settings-list/index.js +4 -0
  346. package/registry/blocks/settings-list/row-state.jsx +93 -0
  347. package/registry/blocks/settings-list/settings-list.jsx +349 -0
  348. package/registry/blocks/settings-list/settings-matrix.jsx +118 -0
  349. package/registry/blocks/setup-checklist/README.md +142 -0
  350. package/registry/blocks/setup-checklist/block.json +10 -0
  351. package/registry/blocks/setup-checklist/demo.jsx +159 -0
  352. package/registry/blocks/setup-checklist/index.js +4 -0
  353. package/registry/blocks/setup-checklist/setup-checklist.jsx +446 -0
  354. package/registry/blocks/share-dialog/README.md +113 -0
  355. package/registry/blocks/share-dialog/block.json +10 -0
  356. package/registry/blocks/share-dialog/demo.jsx +140 -0
  357. package/registry/blocks/share-dialog/index.js +3 -0
  358. package/registry/blocks/share-dialog/share-dialog.jsx +384 -0
  359. package/registry/blocks/sidebar/README.md +190 -0
  360. package/registry/blocks/sidebar/block.json +43 -0
  361. package/registry/blocks/sidebar/demo.jsx +577 -0
  362. package/registry/blocks/sidebar/index.js +4 -0
  363. package/registry/blocks/sidebar/sidebar-panel.jsx +149 -0
  364. package/registry/blocks/sidebar/sidebar.jsx +864 -0
  365. package/registry/blocks/signature-pad/README.md +137 -0
  366. package/registry/blocks/signature-pad/block.json +13 -0
  367. package/registry/blocks/signature-pad/colors.js +115 -0
  368. package/registry/blocks/signature-pad/demo.jsx +94 -0
  369. package/registry/blocks/signature-pad/index.js +6 -0
  370. package/registry/blocks/signature-pad/pad.js +7 -0
  371. package/registry/blocks/signature-pad/signature-pad.jsx +434 -0
  372. package/registry/blocks/signature-pad/signature.js +68 -0
  373. package/registry/blocks/sketch-canvas/README.md +280 -0
  374. package/registry/blocks/sketch-canvas/block.json +13 -0
  375. package/registry/blocks/sketch-canvas/demo.jsx +159 -0
  376. package/registry/blocks/sketch-canvas/excalidraw.js +40 -0
  377. package/registry/blocks/sketch-canvas/index.js +9 -0
  378. package/registry/blocks/sketch-canvas/palette.js +226 -0
  379. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1188 -0
  380. package/registry/blocks/sketch-canvas/sketch.js +179 -0
  381. package/registry/blocks/sketch-canvas/ssr-shim-end.js +6 -0
  382. package/registry/blocks/sketch-canvas/ssr-shim.js +45 -0
  383. package/registry/blocks/sketch-canvas/stamps.js +104 -0
  384. package/registry/blocks/sketch-canvas/theme.js +131 -0
  385. package/registry/blocks/spreadsheet/README.md +307 -0
  386. package/registry/blocks/spreadsheet/block.json +13 -0
  387. package/registry/blocks/spreadsheet/cells.jsx +455 -0
  388. package/registry/blocks/spreadsheet/chrome.jsx +325 -0
  389. package/registry/blocks/spreadsheet/demo.jsx +177 -0
  390. package/registry/blocks/spreadsheet/grid.js +387 -0
  391. package/registry/blocks/spreadsheet/index.js +34 -0
  392. package/registry/blocks/spreadsheet/spreadsheet.jsx +1732 -0
  393. package/registry/blocks/spreadsheet/types.js +473 -0
  394. package/registry/blocks/stat-cards/README.md +173 -0
  395. package/registry/blocks/stat-cards/block.json +10 -0
  396. package/registry/blocks/stat-cards/demo.jsx +257 -0
  397. package/registry/blocks/stat-cards/index.js +4 -0
  398. package/registry/blocks/stat-cards/stat-cards.jsx +307 -0
  399. package/registry/blocks/stat-cards/usage.jsx +243 -0
  400. package/registry/blocks/task-list/README.md +176 -0
  401. package/registry/blocks/task-list/block.json +10 -0
  402. package/registry/blocks/task-list/demo.jsx +134 -0
  403. package/registry/blocks/task-list/index.js +5 -0
  404. package/registry/blocks/task-list/task-list.jsx +907 -0
  405. package/registry/blocks/task-model/README.md +455 -0
  406. package/registry/blocks/task-model/block.json +167 -0
  407. package/registry/blocks/task-model/changes.js +89 -0
  408. package/registry/blocks/task-model/dates.js +371 -0
  409. package/registry/blocks/task-model/demo.jsx +215 -0
  410. package/registry/blocks/task-model/dnd.jsx +240 -0
  411. package/registry/blocks/task-model/index.js +97 -0
  412. package/registry/blocks/task-model/model.js +919 -0
  413. package/registry/blocks/task-model/parts.jsx +449 -0
  414. package/registry/blocks/task-model/toolbar.jsx +271 -0
  415. package/registry/blocks/testimonials/README.md +139 -0
  416. package/registry/blocks/testimonials/block.json +11 -0
  417. package/registry/blocks/testimonials/demo.jsx +137 -0
  418. package/registry/blocks/testimonials/index.js +4 -0
  419. package/registry/blocks/testimonials/testimonials.jsx +430 -0
  420. package/registry/blocks/transaction-list/README.md +126 -0
  421. package/registry/blocks/transaction-list/block.json +10 -0
  422. package/registry/blocks/transaction-list/demo.jsx +79 -0
  423. package/registry/blocks/transaction-list/index.js +4 -0
  424. package/registry/blocks/transaction-list/transaction-list.jsx +383 -0
  425. package/registry/blocks/whiteboard/README.md +179 -0
  426. package/registry/blocks/whiteboard/block.json +10 -0
  427. package/registry/blocks/whiteboard/demo.jsx +169 -0
  428. package/registry/blocks/whiteboard/index.js +6 -0
  429. package/registry/blocks/whiteboard/templates.js +150 -0
  430. package/registry/blocks/whiteboard/whiteboard.jsx +721 -0
  431. package/src/install.mjs +18 -0
  432. package/src/shadcn.mjs +171 -0
  433. package/templates/next/files/README.md +145 -11
  434. package/templates/next/files/app/globals.css +55 -4
  435. package/templates/next/files/check/run.mjs +3 -3
  436. package/templates/next/files/components/parts/fixture.jsx +1 -1
  437. package/templates/next/files/components/parts/sample.js +15 -0
  438. package/templates/next/files/components/ui/avatar.jsx +141 -0
  439. package/templates/next/files/components/ui/breadcrumb.jsx +149 -0
  440. package/templates/next/files/components/ui/button-group.jsx +70 -0
  441. package/templates/next/files/components/ui/button.jsx +132 -12
  442. package/templates/next/files/components/ui/calendar.jsx +609 -0
  443. package/templates/next/files/components/ui/carousel.jsx +281 -0
  444. package/templates/next/files/components/ui/collapsible.jsx +208 -0
  445. package/templates/next/files/components/ui/combobox.jsx +608 -0
  446. package/templates/next/files/components/ui/command.jsx +577 -0
  447. package/templates/next/files/components/ui/context-menu.jsx +191 -0
  448. package/templates/next/files/components/ui/date-picker.jsx +360 -0
  449. package/templates/next/files/components/ui/distribution.jsx +117 -0
  450. package/templates/next/files/components/ui/feedback.jsx +143 -0
  451. package/templates/next/files/components/ui/field.jsx +115 -3
  452. package/templates/next/files/components/ui/file-input.jsx +234 -0
  453. package/templates/next/files/components/ui/icon.jsx +130 -8
  454. package/templates/next/files/components/ui/image.jsx +143 -0
  455. package/templates/next/files/components/ui/index.js +47 -2
  456. package/templates/next/files/components/ui/input-group.jsx +213 -0
  457. package/templates/next/files/components/ui/input-otp.jsx +170 -0
  458. package/templates/next/files/components/ui/item.jsx +206 -0
  459. package/templates/next/files/components/ui/menu.jsx +454 -82
  460. package/templates/next/files/components/ui/notice.jsx +126 -14
  461. package/templates/next/files/components/ui/pagination.jsx +156 -0
  462. package/templates/next/files/components/ui/popover.jsx +284 -0
  463. package/templates/next/files/components/ui/radio.jsx +193 -0
  464. package/templates/next/files/components/ui/rating.jsx +131 -0
  465. package/templates/next/files/components/ui/resizable.jsx +338 -0
  466. package/templates/next/files/components/ui/scroll-area.jsx +123 -0
  467. package/templates/next/files/components/ui/separator.jsx +119 -0
  468. package/templates/next/files/components/ui/sheet.jsx +96 -11
  469. package/templates/next/files/components/ui/slider.jsx +174 -0
  470. package/templates/next/files/components/ui/stat.jsx +574 -0
  471. package/templates/next/files/components/ui/steps.jsx +338 -0
  472. package/templates/next/files/components/ui/swatch.jsx +33 -0
  473. package/templates/next/files/components/ui/table.jsx +130 -0
  474. package/templates/next/files/components/ui/toast.jsx +440 -0
  475. package/templates/next/files/components/ui/toggle.jsx +195 -0
  476. package/templates/next/files/components/ui/tooltip.jsx +270 -0
  477. package/templates/next/files/lib/sample.js +4 -12
  478. package/templates/next/template.json +264 -8
@@ -0,0 +1,1732 @@
1
+ "use client";
2
+
3
+ // The Spreadsheet: a table you edit like Sheets or Airtable. Rows and
4
+ // columns come in as props; every edit goes out through an async callback
5
+ // that answers { ok }, applied at once and rolled back if it says no.
6
+ //
7
+ // Virtualised both ways (@tanstack/react-virtual): only the cells in the
8
+ // window are in the DOM, so 10,000 rows scroll like 10. Rows have one fixed
9
+ // height, which keeps every position arithmetic — no measuring.
10
+ //
11
+ // Layout: one scroll box. Inside it, a content box the size of the whole
12
+ // table; the header row is sticky to its top; each row is absolutely
13
+ // placed at its offset. In a row (and in the header) the row-number
14
+ // gutter and the frozen columns are in flow and sticky to the left; the
15
+ // other cells are absolutely placed at their column's offset, so the
16
+ // virtualiser may hand back any set of them.
17
+ import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
18
+ import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
19
+ import {
20
+ Badge,
21
+ Button,
22
+ ContextMenu,
23
+ Icon,
24
+ IconButton,
25
+ InputGroup,
26
+ Menu,
27
+ Popover,
28
+ Skeleton,
29
+ cx,
30
+ toast,
31
+ } from "../../ui";
32
+ import { CellValue, editorFor } from "./cells";
33
+ import { AddFieldPanel, FieldsPanel, FilterPanel, ShortcutsPanel, SortPanel, TYPE_ORDER, sortWords, uid } from "./chrome";
34
+ import {
35
+ applyChanges,
36
+ clamp,
37
+ clearChanges,
38
+ fillChanges,
39
+ inRange,
40
+ invertChanges,
41
+ moveBy,
42
+ normalizeView,
43
+ orderedColumns,
44
+ parseTSV,
45
+ pasteChanges,
46
+ processRows,
47
+ rangeOf,
48
+ rangeText,
49
+ realChanges,
50
+ summarize,
51
+ toTSV,
52
+ visibleColumns,
53
+ } from "./grid";
54
+ import { convertValue, isBlank, parseCell, typeOf } from "./types";
55
+
56
+ const SIZES = {
57
+ desk: { row: 36, head: 40, gutter: 64 },
58
+ touch: { row: 44, head: 44, gutter: 52 },
59
+ };
60
+ const ADD_COL = 48; // the "+" at the end of the header
61
+ const MIN_W = 64;
62
+ const MAX_W = 800;
63
+ const FROZEN_NARROW = 148; // a frozen column's widest on a phone
64
+
65
+ const plural = (n, noun) => `${n.toLocaleString()} ${n === 1 ? noun : `${noun}s`}`;
66
+
67
+ async function call(fn, ...args) {
68
+ if (!fn) return { ok: true };
69
+ try {
70
+ const r = await fn(...args);
71
+ return r ?? { ok: true };
72
+ } catch (e) {
73
+ return { ok: false, error: e?.message };
74
+ }
75
+ }
76
+
77
+ const isMac = () => typeof navigator !== "undefined" && /Mac|iP(hone|ad)/.test(navigator.platform || navigator.userAgent);
78
+
79
+ export function Spreadsheet({
80
+ rows = [],
81
+ columns = [],
82
+ onChange,
83
+ onAddRows,
84
+ onDeleteRows,
85
+ onColumnsChange,
86
+ view: viewProp,
87
+ defaultView,
88
+ onViewChange,
89
+ people,
90
+ types,
91
+ validate,
92
+ readOnly = false,
93
+ lockFields = false,
94
+ onExpandRow,
95
+ onSelectionChange,
96
+ createRow,
97
+ loading = false,
98
+ label = "Table",
99
+ noun = "row",
100
+ height = 560,
101
+ toolbar = true,
102
+ toolbarStart,
103
+ toolbarEnd,
104
+ empty,
105
+ locale,
106
+ currency,
107
+ today,
108
+ className,
109
+ ref,
110
+ }) {
111
+ const gid = useId().replace(/[^\w-]/g, "");
112
+ const custom = types;
113
+
114
+ // ── data: the props, with optimistic edits laid on ───────────────────
115
+ const [local, setLocal] = useState(rows);
116
+ const [seenRows, setSeenRows] = useState(rows);
117
+ if (rows !== seenRows) {
118
+ setSeenRows(rows);
119
+ setLocal(rows);
120
+ }
121
+ const [localCols, setLocalCols] = useState(columns);
122
+ const [seenCols, setSeenCols] = useState(columns);
123
+ if (columns !== seenCols) {
124
+ setSeenCols(columns);
125
+ setLocalCols(columns);
126
+ }
127
+ const localRef = useRef(local);
128
+ localRef.current = local;
129
+ const colsRef = useRef(localCols);
130
+ colsRef.current = localCols;
131
+
132
+ // ── the view ─────────────────────────────────────────────────────────
133
+ const [ownView, setOwnView] = useState(() => normalizeView(defaultView));
134
+ const view = useMemo(() => normalizeView(viewProp ?? ownView), [viewProp, ownView]);
135
+ const viewRef = useRef(view);
136
+ viewRef.current = view;
137
+ const setView = useCallback(
138
+ (patch) => {
139
+ const next = { ...viewRef.current, ...(typeof patch === "function" ? patch(viewRef.current) : patch) };
140
+ viewRef.current = next;
141
+ if (viewProp === undefined) setOwnView(next);
142
+ onViewChange?.(next);
143
+ },
144
+ [viewProp, onViewChange],
145
+ );
146
+ const [search, setSearch] = useState("");
147
+
148
+ // ── the box: phone or desk ───────────────────────────────────────────
149
+ const rootRef = useRef(null);
150
+ const scroller = useRef(null);
151
+ const [boxW, setBoxW] = useState(null);
152
+ const [coarse, setCoarse] = useState(false);
153
+ useLayoutEffect(() => {
154
+ const el = rootRef.current;
155
+ if (!el) return;
156
+ setBoxW(el.clientWidth);
157
+ const ro = new ResizeObserver(() => setBoxW(el.clientWidth));
158
+ ro.observe(el);
159
+ const mq = window.matchMedia?.("(pointer: coarse)");
160
+ const onMq = () => setCoarse(Boolean(mq?.matches));
161
+ onMq();
162
+ mq?.addEventListener?.("change", onMq);
163
+ return () => {
164
+ ro.disconnect();
165
+ mq?.removeEventListener?.("change", onMq);
166
+ };
167
+ }, []);
168
+ const narrow = boxW != null && boxW < 640;
169
+ const size = narrow || coarse ? SIZES.touch : SIZES.desk;
170
+ const rowH = size.row;
171
+ const headH = size.head;
172
+ const gutterW = size.gutter;
173
+
174
+ const ctx = useMemo(() => ({ people: people ?? [], locale, currency }), [people, locale, currency]);
175
+ const canEditCols = Boolean(onColumnsChange) && !readOnly && !lockFields;
176
+ const canAdd = Boolean(onAddRows) && !readOnly;
177
+
178
+ // ── columns in view ──────────────────────────────────────────────────
179
+ const [resizing, setResizing] = useState(null); // { key, width } while a handle is dragged
180
+ const cols = useMemo(() => {
181
+ const list = visibleColumns(localCols, view, custom, narrow);
182
+ return list.map((c, i) => {
183
+ let w = resizing?.key === c.key ? resizing.width : c.width;
184
+ if (narrow && i < view.frozen) w = Math.min(w, FROZEN_NARROW);
185
+ return w === c.width ? c : { ...c, width: w };
186
+ });
187
+ }, [localCols, view, custom, narrow, resizing]);
188
+ const frozenN = Math.min(view.frozen, cols.length);
189
+ const colLeft = useMemo(() => {
190
+ const out = [];
191
+ let x = gutterW;
192
+ for (const c of cols) {
193
+ out.push(x);
194
+ x += c.width;
195
+ }
196
+ out.push(x);
197
+ return out;
198
+ }, [cols, gutterW]);
199
+ const frozenW = colLeft[frozenN] - gutterW;
200
+ const totalW = colLeft[cols.length] + (canEditCols ? ADD_COL : 0);
201
+ const colIndex = useMemo(() => new Map(cols.map((c, i) => [c.key, i])), [cols]);
202
+ const typeAt = (c) => typeOf(cols[c], custom);
203
+
204
+ // ── rows in view: filtered and sorted, then HELD ──────────────────────
205
+ // An edit does not re-sort or re-filter: the row stays under the cursor
206
+ // (as in Airtable) until the view changes or "Sort again" is pressed.
207
+ // Rows added here go where they were added; rows that arrive from the
208
+ // outside re-run the view.
209
+ const [resortTick, setResortTick] = useState(0);
210
+ const order = useRef({ key: null, ids: [], dirty: false });
211
+ const addedHere = useRef(new Set());
212
+ const hints = useRef(new Map()); // new row id → { after, before }
213
+ const viewRows = useMemo(() => {
214
+ const byId = new Map(local.map((r) => [r.id, r]));
215
+ const key = [view.sort, view.filters, view.hidden, search, localCols, custom, ctx, resortTick];
216
+ const prev = order.current;
217
+ let ids = null;
218
+ if (prev.key && key.every((k, i) => k === prev.key[i])) {
219
+ const known = new Set(prev.ids);
220
+ const fresh = [];
221
+ for (const r of local) if (!known.has(r.id)) fresh.push(r.id);
222
+ if (fresh.every((id) => addedHere.current.has(id)) && prev.all?.size !== undefined) {
223
+ ids = prev.ids.filter((id) => byId.has(id));
224
+ for (const id of fresh) {
225
+ const h = hints.current.get(id);
226
+ const at = h ? ids.indexOf(h.ref) : -1;
227
+ if (at < 0) ids.push(id);
228
+ else ids.splice(h.before ? at : at + 1, 0, id);
229
+ }
230
+ }
231
+ }
232
+ if (!ids) {
233
+ ids = processRows(local, localCols, view, { search, ctx, custom }).map((r) => r.id);
234
+ order.current = { key, ids, dirty: false, all: new Set(ids) };
235
+ } else order.current = { ...prev, key, ids };
236
+ return ids.map((id) => byId.get(id));
237
+ }, [local, localCols, view, search, custom, ctx, resortTick]);
238
+ const rowIndex = useMemo(() => new Map(viewRows.map((r, i) => [r.id, i])), [viewRows]);
239
+ const rowById = useMemo(() => new Map(local.map((r) => [r.id, r])), [local]);
240
+ const filtered = view.filters.some((f) => !isBlank(f.value) || typeOf(localCols.find((c) => c.key === f.key), custom).allFilters.find((o) => o.id === f.op)?.arg === "none") || search.trim() !== "";
241
+
242
+ // ── problems (validate + required) ────────────────────────────────────
243
+ const problems = useMemo(() => {
244
+ const out = new Map();
245
+ const needs = localCols.filter((c) => c.required);
246
+ if (!validate && !needs.length) return out;
247
+ const check = validate ? localCols : needs;
248
+ for (const r of local) {
249
+ for (const c of check) {
250
+ let msg = null;
251
+ if (c.required && typeOf(c, custom).empty(r[c.key])) msg = `${c.name} is required`;
252
+ else if (validate) msg = validate(r, c) || null;
253
+ if (msg) out.set(`${r.id}\u0000${c.key}`, msg);
254
+ }
255
+ }
256
+ return out;
257
+ }, [local, localCols, validate, custom]);
258
+
259
+ // ── selection ────────────────────────────────────────────────────────
260
+ // By row id and column key, so it follows a row through a sort.
261
+ const [sel, setSel] = useState(null); // { a: {id, key}, f: {id, key}, rows?: true }
262
+ const [editing, setEditing] = useState(null); // { id, key, initial }
263
+ const editingRef = useRef(editing);
264
+ editingRef.current = editing;
265
+ const A = sel && rowIndex.has(sel.a.id) && colIndex.has(sel.a.key) ? { r: rowIndex.get(sel.a.id), c: colIndex.get(sel.a.key) } : null;
266
+ const F = A && sel.f && rowIndex.has(sel.f.id) && colIndex.has(sel.f.key) ? { r: rowIndex.get(sel.f.id), c: colIndex.get(sel.f.key) } : A;
267
+ const range = A ? rangeOf(A, F) : null;
268
+ const single = range && range.r0 === range.r1 && range.c0 === range.c1;
269
+ const rowsMode = Boolean(sel?.rows && range);
270
+ const stateRef = useRef({});
271
+ stateRef.current = { A, F, range, viewRows, cols, rowsMode };
272
+
273
+ const at = (r, c) => ({ id: viewRows[r]?.id, key: cols[c]?.key });
274
+ const select = useCallback(
275
+ (a, f = a, opts = {}) => {
276
+ const { viewRows: vr, cols: cs } = stateRef.current;
277
+ if (!vr.length || !cs.length) return setSel(null);
278
+ const ca = { r: clamp(a.r, 0, vr.length - 1), c: clamp(a.c, 0, cs.length - 1) };
279
+ const cf = { r: clamp(f.r, 0, vr.length - 1), c: clamp(f.c, 0, cs.length - 1) };
280
+ setSel({ a: { id: vr[ca.r].id, key: cs[ca.c].key }, f: { id: vr[cf.r].id, key: cs[cf.c].key }, rows: opts.rows || undefined });
281
+ },
282
+ [],
283
+ );
284
+
285
+ const focusGrid = useCallback(() => scroller.current?.focus({ preventScroll: true }), []);
286
+
287
+ // Keep a cell in the window: under the sticky header, right of the
288
+ // frozen columns.
289
+ const ensureVisible = useCallback(
290
+ (r, c) => {
291
+ const el = scroller.current;
292
+ if (!el) return;
293
+ if (r != null && r >= 0) {
294
+ const top = headH + r * rowH;
295
+ if (top < el.scrollTop + headH) el.scrollTop = top - headH;
296
+ else if (top + rowH > el.scrollTop + el.clientHeight) el.scrollTop = top + rowH - el.clientHeight;
297
+ }
298
+ if (c != null && c >= frozenN && c < cols.length) {
299
+ const left = colLeft[c];
300
+ const right = colLeft[c + 1];
301
+ const fixed = gutterW + frozenW;
302
+ if (left < el.scrollLeft + fixed) el.scrollLeft = left - fixed;
303
+ else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = right - el.clientWidth;
304
+ }
305
+ },
306
+ [headH, rowH, frozenN, cols.length, colLeft, gutterW, frozenW],
307
+ );
308
+
309
+ // A row added here is selected and shown once it renders.
310
+ const pendingFocus = useRef(null);
311
+ useEffect(() => {
312
+ const p = pendingFocus.current;
313
+ if (!p || !rowIndex.has(p.id)) return;
314
+ pendingFocus.current = null;
315
+ const r = rowIndex.get(p.id);
316
+ select({ r, c: 0 });
317
+ ensureVisible(r, 0);
318
+ if (p.edit && cols[0]) setEditing({ id: p.id, key: cols[0].key, initial: null });
319
+ else focusGrid();
320
+ }, [rowIndex, select, ensureVisible, focusGrid, cols]);
321
+
322
+ useEffect(() => {
323
+ if (!onSelectionChange) return;
324
+ if (!range) return onSelectionChange(null);
325
+ onSelectionChange({
326
+ active: { rowId: viewRows[A.r]?.id, key: cols[A.c]?.key },
327
+ rowIds: viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id),
328
+ keys: cols.slice(range.c0, range.c1 + 1).map((c) => c.key),
329
+ });
330
+ // The ids, not the objects: a value edit is not a selection change.
331
+ // eslint-disable-next-line react-hooks/exhaustive-deps
332
+ }, [sel, viewRows.length, cols.length]);
333
+
334
+ // ── history ──────────────────────────────────────────────────────────
335
+ const undoStack = useRef([]);
336
+ const redoStack = useRef([]);
337
+ const [, setHist] = useState(0);
338
+ const bump = () => setHist((n) => n + 1);
339
+
340
+ // ── saving ───────────────────────────────────────────────────────────
341
+ const updateColumns = useCallback(
342
+ async (next, reason) => {
343
+ const prev = colsRef.current;
344
+ colsRef.current = next;
345
+ setLocalCols(next);
346
+ const res = await call(onColumnsChange, next, { reason });
347
+ if (!res.ok) {
348
+ colsRef.current = prev;
349
+ setLocalCols(prev);
350
+ toast.error("Couldn’t change the fields", { description: res.error ?? "Nothing was changed. Try again." });
351
+ }
352
+ return res;
353
+ },
354
+ [onColumnsChange],
355
+ );
356
+
357
+ const commitChanges = useCallback(
358
+ async (input, { creates, history = true } = {}) => {
359
+ const changes = realChanges(input);
360
+ if (creates?.size) {
361
+ const next = colsRef.current.map((c) => (creates.has(c.key) ? { ...c, options: [...(c.options ?? []), ...creates.get(c.key)] } : c));
362
+ updateColumns(next, "options");
363
+ }
364
+ if (!changes.length) return { ok: true };
365
+ setLocal((rs) => applyChanges(rs, changes));
366
+ const v = viewRef.current;
367
+ const watched = new Set([...v.sort.map((s) => s.key), ...v.filters.map((f) => f.key)]);
368
+ if (changes.some((ch) => watched.has(ch.key))) order.current.dirty = true;
369
+ const step = { kind: "cells", changes };
370
+ if (history) {
371
+ undoStack.current.push(step);
372
+ if (undoStack.current.length > 100) undoStack.current.shift();
373
+ redoStack.current = [];
374
+ bump();
375
+ }
376
+ const res = await call(onChange, changes);
377
+ if (!res.ok) {
378
+ setLocal((rs) => applyChanges(rs, changes, "prev"));
379
+ undoStack.current = undoStack.current.filter((s) => s !== step);
380
+ bump();
381
+ toast.error(changes.length === 1 ? "Couldn’t save that change" : `Couldn’t save ${changes.length} changes`, {
382
+ description: res.error ?? "It was put back as it was. Try again.",
383
+ });
384
+ }
385
+ return res;
386
+ },
387
+ [onChange, updateColumns],
388
+ );
389
+
390
+ const newRow = useCallback(
391
+ (values = {}) => {
392
+ const base = createRow ? createRow() : {};
393
+ return { ...base, id: base.id ?? uid("row"), ...values };
394
+ },
395
+ [createRow],
396
+ );
397
+
398
+ const addRows = useCallback(
399
+ async (list, { index, hint, edit = false, history = true, restore = false } = {}) => {
400
+ if (!list.length) return { ok: true };
401
+ for (const r of list) addedHere.current.add(r.id);
402
+ if (hint) list.forEach((r, i) => hints.current.set(r.id, i === 0 ? hint : { ref: list[i - 1].id }));
403
+ setLocal((rs) => (index == null ? [...rs, ...list] : [...rs.slice(0, index), ...list, ...rs.slice(index)]));
404
+ pendingFocus.current = restore ? null : { id: list[0].id, edit };
405
+ const step = { kind: "add", rows: list, index };
406
+ if (history) {
407
+ undoStack.current.push(step);
408
+ redoStack.current = [];
409
+ bump();
410
+ }
411
+ const res = await call(onAddRows, list, { index, restore });
412
+ if (!res.ok) {
413
+ const gone = new Set(list.map((r) => r.id));
414
+ setLocal((rs) => rs.filter((r) => !gone.has(r.id)));
415
+ undoStack.current = undoStack.current.filter((s) => s !== step);
416
+ bump();
417
+ toast.error(list.length === 1 ? `Couldn’t add the ${noun}` : `Couldn’t add ${plural(list.length, noun)}`, { description: res.error ?? "Try again." });
418
+ } else if (Array.isArray(res.rows)) {
419
+ // The server named them: swap the ids we made up for its own.
420
+ const swap = new Map(list.map((r, i) => [r.id, res.rows[i]]).filter(([, x]) => x));
421
+ if (swap.size) {
422
+ for (const x of swap.values()) addedHere.current.add(x.id);
423
+ setLocal((rs) => rs.map((r) => (swap.has(r.id) ? { ...r, ...swap.get(r.id) } : r)));
424
+ order.current = { ...order.current, ids: order.current.ids.map((id) => (swap.has(id) ? swap.get(id).id ?? id : id)) };
425
+ setSel((s) => (s && swap.has(s.a.id) ? { ...s, a: { ...s.a, id: swap.get(s.a.id).id ?? s.a.id }, f: { ...s.f, id: swap.get(s.f.id)?.id ?? s.f.id } } : s));
426
+ }
427
+ }
428
+ return res;
429
+ },
430
+ [onAddRows, noun],
431
+ );
432
+
433
+ const deleteRows = useCallback(
434
+ async (ids, { history = true, quiet = false } = {}) => {
435
+ const gone = new Set(ids);
436
+ const removed = [];
437
+ localRef.current.forEach((r, i) => {
438
+ if (gone.has(r.id)) removed.push({ row: r, index: i });
439
+ });
440
+ if (!removed.length) return { ok: true };
441
+ setLocal((rs) => rs.filter((r) => !gone.has(r.id)));
442
+ setSel(null);
443
+ const step = { kind: "delete", removed };
444
+ if (history) {
445
+ undoStack.current.push(step);
446
+ redoStack.current = [];
447
+ bump();
448
+ }
449
+ const res = await call(onDeleteRows, ids);
450
+ if (!res.ok) {
451
+ setLocal((rs) => restoreRows(rs, removed));
452
+ undoStack.current = undoStack.current.filter((s) => s !== step);
453
+ bump();
454
+ toast.error(`Couldn’t delete ${removed.length === 1 ? `the ${noun}` : plural(removed.length, noun)}`, { description: res.error ?? "Nothing was deleted. Try again." });
455
+ } else if (!quiet) {
456
+ toast(removed.length === 1 ? `Deleted 1 ${noun}` : `Deleted ${plural(removed.length, noun)}`, {
457
+ action: { label: "Undo", onClick: () => undoRef.current?.() },
458
+ });
459
+ }
460
+ return res;
461
+ },
462
+ [onDeleteRows, noun],
463
+ );
464
+
465
+ async function undo() {
466
+ const step = undoStack.current.pop();
467
+ if (!step) return;
468
+ bump();
469
+ if (step.kind === "cells") await commitChanges(invertChanges(step.changes), { history: false });
470
+ else if (step.kind === "add") await deleteRows(step.rows.map((r) => r.id), { history: false, quiet: true });
471
+ else if (step.kind === "delete") {
472
+ for (const { row } of step.removed) addedHere.current.add(row.id);
473
+ setLocal((rs) => restoreRows(rs, step.removed));
474
+ await call(onAddRows, step.removed.map((x) => x.row), { restore: true, index: step.removed[0].index });
475
+ }
476
+ redoStack.current.push(step);
477
+ bump();
478
+ focusGrid();
479
+ }
480
+ async function redo() {
481
+ const step = redoStack.current.pop();
482
+ if (!step) return;
483
+ bump();
484
+ if (step.kind === "cells") await commitChanges(step.changes, { history: false });
485
+ else if (step.kind === "add") await addRows(step.rows, { index: step.index, history: false, restore: true });
486
+ else if (step.kind === "delete") await deleteRows(step.removed.map((x) => x.row.id), { history: false, quiet: true });
487
+ undoStack.current.push(step);
488
+ bump();
489
+ focusGrid();
490
+ }
491
+ const undoRef = useRef(undo);
492
+ undoRef.current = undo;
493
+
494
+ // ── editing ──────────────────────────────────────────────────────────
495
+ const editable = (c) => !readOnly && cols[c] && !cols[c].readOnly && !cols[c].computed;
496
+
497
+ function startEdit(r, c, initial = null) {
498
+ if (!editable(c) || !viewRows[r]) return;
499
+ const t = typeAt(c);
500
+ const row = viewRows[r];
501
+ if (t.editor === "toggle") return commitChanges([{ rowId: row.id, key: cols[c].key, value: !row[cols[c].key], prev: row[cols[c].key] }]);
502
+ if (!editorFor(t)) return;
503
+ select({ r, c });
504
+ ensureVisible(r, c);
505
+ setEditing({ id: row.id, key: cols[c].key, initial });
506
+ }
507
+
508
+ function moveActive(move) {
509
+ const s = stateRef.current;
510
+ if (!s.A) return;
511
+ const d = { down: [1, 0], up: [-1, 0], right: [0, 1], left: [0, -1] }[move];
512
+ if (!d) return;
513
+ const next = { r: clamp(s.A.r + d[0], 0, s.viewRows.length - 1), c: clamp(s.A.c + d[1], 0, s.cols.length - 1) };
514
+ select(next);
515
+ ensureVisible(next.r, next.c);
516
+ }
517
+
518
+ function commitEdit(value, move, extra) {
519
+ const ed = editingRef.current;
520
+ if (!ed) return;
521
+ const row = rowById.get(ed.id);
522
+ const creates = extra?.create?.length ? new Map([[ed.key, extra.create]]) : null;
523
+ setEditing(null);
524
+ focusGrid();
525
+ commitChanges([{ rowId: ed.id, key: ed.key, value, prev: row?.[ed.key] }], { creates });
526
+ if (move) moveActive(move);
527
+ }
528
+ function cancelEdit() {
529
+ setEditing(null);
530
+ focusGrid();
531
+ }
532
+
533
+ // ── clipboard ────────────────────────────────────────────────────────
534
+ function copyText() {
535
+ if (!range) return "";
536
+ return toTSV(rangeText(range, viewRows, cols, ctx, custom));
537
+ }
538
+
539
+ function pasteText(text) {
540
+ const matrix = parseTSV(text);
541
+ if (!matrix.length || readOnly) return;
542
+ // An empty table: the paste becomes its rows.
543
+ const target = range ?? { r0: viewRows.length, r1: viewRows.length, c0: 0, c1: 0 };
544
+ const { changes, errors, creates, extra } = pasteChanges(matrix, target, viewRows, cols, ctx, custom);
545
+ commitChanges(changes, { creates });
546
+ if (extra.length && canAdd) {
547
+ const made = extra.map((line) => {
548
+ const values = {};
549
+ line.forEach((text, j) => {
550
+ const col = cols[target.c0 + j];
551
+ if (!col || col.readOnly) return;
552
+ const res = parseCell(text, col, ctx, custom);
553
+ if (!("error" in res) && !res.create) values[col.key] = res.value;
554
+ });
555
+ return newRow(values);
556
+ });
557
+ addRows(made);
558
+ }
559
+ const h = matrix.length === 1 && matrix[0].length === 1 ? target.r1 - target.r0 : matrix.length - 1;
560
+ const w = matrix.length === 1 && matrix[0].length === 1 ? target.c1 - target.c0 : Math.max(...matrix.map((l) => l.length)) - 1;
561
+ if (range) select({ r: target.r0, c: target.c0 }, { r: Math.min(target.r0 + h, viewRows.length - 1), c: Math.min(target.c0 + w, cols.length - 1) });
562
+ if (errors.length) {
563
+ toast.warning(errors.length === 1 ? "1 cell was skipped" : `${errors.length} cells were skipped`, {
564
+ description: `${errors[0].error}${errors.length > 1 ? ", and more" : ""}. The rest were pasted.`,
565
+ });
566
+ }
567
+ }
568
+
569
+ // ── keys ─────────────────────────────────────────────────────────────
570
+ function onKeyDown(e) {
571
+ if (editingRef.current) return;
572
+ if (e.target !== scroller.current) return; // a header menu, an input
573
+ const mod = e.metaKey || e.ctrlKey;
574
+ const k = e.key;
575
+ if (!viewRows.length || !cols.length) return;
576
+ const s = A ?? null;
577
+
578
+ if (mod && (k === "z" || k === "Z")) {
579
+ e.preventDefault();
580
+ return e.shiftKey ? redo() : undo();
581
+ }
582
+ if (mod && (k === "y" || k === "Y")) {
583
+ e.preventDefault();
584
+ return redo();
585
+ }
586
+ if (mod && (k === "a" || k === "A")) {
587
+ e.preventDefault();
588
+ return select({ r: 0, c: 0 }, { r: viewRows.length - 1, c: cols.length - 1 });
589
+ }
590
+ if (!s) {
591
+ if (k.startsWith("Arrow") || k === "Tab" || k === "Enter") {
592
+ e.preventDefault();
593
+ select({ r: 0, c: 0 });
594
+ ensureVisible(0, 0);
595
+ }
596
+ return;
597
+ }
598
+ const page = Math.max(1, Math.floor(((scroller.current?.clientHeight ?? 400) - headH) / rowH) - 1);
599
+ const moved = moveBy(k, e.shiftKey ? F : s, { rows: viewRows.length, cols: cols.length, page, jump: mod });
600
+ if (moved) {
601
+ e.preventDefault();
602
+ if (e.shiftKey) select(s, moved, { rows: rowsMode && moved.c === F.c ? true : undefined });
603
+ else select(moved);
604
+ ensureVisible(moved.r, moved.c);
605
+ return;
606
+ }
607
+ if (k === "Tab") {
608
+ e.preventDefault();
609
+ let { r, c } = s;
610
+ if (e.shiftKey) {
611
+ if (c > 0) c--;
612
+ else if (r > 0) [r, c] = [r - 1, cols.length - 1];
613
+ } else if (c < cols.length - 1) c++;
614
+ else if (r < viewRows.length - 1) [r, c] = [r + 1, 0];
615
+ select({ r, c });
616
+ ensureVisible(r, c);
617
+ return;
618
+ }
619
+ if (k === "Escape") {
620
+ if (!single) {
621
+ e.preventDefault();
622
+ select(s);
623
+ }
624
+ return;
625
+ }
626
+ if (k === "Enter" || k === "F2") {
627
+ e.preventDefault();
628
+ if (k === "Enter" && mod && onExpandRow) return onExpandRow(viewRows[s.r]);
629
+ return startEdit(s.r, s.c);
630
+ }
631
+ if (k === "Delete" || k === "Backspace") {
632
+ e.preventDefault();
633
+ if (readOnly) return;
634
+ return commitChanges(clearChanges(range, viewRows, cols, ctx, custom));
635
+ }
636
+ if (k === " " && e.shiftKey) {
637
+ e.preventDefault();
638
+ return select({ r: range.r0, c: 0 }, { r: range.r1, c: cols.length - 1 }, { rows: true });
639
+ }
640
+ if (k === " " && mod) {
641
+ e.preventDefault();
642
+ return select({ r: 0, c: range.c0 }, { r: viewRows.length - 1, c: range.c1 });
643
+ }
644
+ if (mod && (k === "d" || k === "D")) {
645
+ e.preventDefault();
646
+ if (readOnly) return;
647
+ // Several rows: the first row fills the rest. One row: the row above
648
+ // fills it, as Sheets does.
649
+ if (range.r1 > range.r0) return commitChanges(fillChanges({ ...range, r1: range.r0 }, range.r1, viewRows, cols));
650
+ if (range.r0 > 0) return commitChanges(fillChanges({ ...range, r0: range.r0 - 1, r1: range.r0 - 1 }, range.r0, viewRows, cols));
651
+ return;
652
+ }
653
+ if (k === " ") {
654
+ e.preventDefault();
655
+ const t = typeAt(s.c);
656
+ if (t.editor === "toggle" && !readOnly) {
657
+ const first = viewRows[range.r0]?.[cols[s.c].key];
658
+ const changes = [];
659
+ for (let r = range.r0; r <= range.r1; r++)
660
+ for (let c = range.c0; c <= range.c1; c++) {
661
+ if (typeAt(c).editor !== "toggle" || !editable(c)) continue;
662
+ changes.push({ rowId: viewRows[r].id, key: cols[c].key, value: !first, prev: viewRows[r][cols[c].key] });
663
+ }
664
+ return commitChanges(changes);
665
+ }
666
+ if (onExpandRow) return onExpandRow(viewRows[s.r]);
667
+ return startEdit(s.r, s.c, " ");
668
+ }
669
+ // A printable key starts an edit with that key as the text.
670
+ if (k.length === 1 && !mod && !e.altKey) {
671
+ const t = typeAt(s.c);
672
+ if (t.editor === "rating" && /[0-9]/.test(k) && editable(s.c)) {
673
+ e.preventDefault();
674
+ const res = parseCell(k, cols[s.c], ctx, custom);
675
+ if (!("error" in res)) commitChanges([{ rowId: viewRows[s.r].id, key: cols[s.c].key, value: res.value, prev: viewRows[s.r][cols[s.c].key] }]);
676
+ return;
677
+ }
678
+ if (editorFor(t) && editable(s.c)) {
679
+ e.preventDefault();
680
+ startEdit(s.r, s.c, k);
681
+ }
682
+ }
683
+ }
684
+
685
+ // ── pointer ──────────────────────────────────────────────────────────
686
+ const drag = useRef(null); // { mode: "select" | "fill", ... }
687
+ const lastPointer = useRef("mouse");
688
+ const [fillTo, setFillToState] = useState(null);
689
+ const fillRef = useRef(null);
690
+ const setFillTo = (v) => {
691
+ fillRef.current = v;
692
+ setFillToState(v);
693
+ };
694
+
695
+ function cellFrom(target) {
696
+ const el = target.closest?.("[data-cell]");
697
+ if (!el || !scroller.current?.contains(el)) return null;
698
+ return { r: Number(el.dataset.r), c: Number(el.dataset.c) };
699
+ }
700
+
701
+ function rowAtY(clientY) {
702
+ const el = scroller.current;
703
+ const rect = el.getBoundingClientRect();
704
+ return clamp(Math.floor((clientY - rect.top + el.scrollTop - headH) / rowH), 0, viewRows.length - 1);
705
+ }
706
+
707
+ function autoScroll(e) {
708
+ const el = scroller.current;
709
+ if (!el) return;
710
+ const rect = el.getBoundingClientRect();
711
+ if (e.clientY > rect.bottom - 28) el.scrollTop += 18;
712
+ else if (e.clientY < rect.top + headH + 12) el.scrollTop -= 18;
713
+ if (e.clientX > rect.right - 28) el.scrollLeft += 18;
714
+ else if (e.clientX < rect.left + gutterW + frozenW + 12 && el.scrollLeft > 0) el.scrollLeft -= 18;
715
+ }
716
+
717
+ useEffect(() => {
718
+ function move(e) {
719
+ const d = drag.current;
720
+ if (!d) return;
721
+ autoScroll(e);
722
+ if (d.mode === "fill") setFillTo(rowAtY(e.clientY));
723
+ else if (d.mode === "select") {
724
+ const hit = cellFrom(document.elementFromPoint(e.clientX, e.clientY) ?? document.body);
725
+ if (hit && (hit.r !== d.last?.r || hit.c !== d.last?.c)) {
726
+ d.last = hit;
727
+ select(d.anchor, hit);
728
+ }
729
+ } else if (d.mode === "rows") {
730
+ const r = rowAtY(e.clientY);
731
+ select({ r: d.anchor, c: 0 }, { r, c: stateRef.current.cols.length - 1 }, { rows: true });
732
+ }
733
+ }
734
+ function up() {
735
+ const d = drag.current;
736
+ drag.current = null;
737
+ if (d?.mode === "fill") {
738
+ const to = fillRef.current;
739
+ setFillTo(null);
740
+ const { range: g, viewRows: vr, cols: cs } = stateRef.current;
741
+ if (g && to != null && (to > g.r1 || to < g.r0)) {
742
+ commitChanges(fillChanges(g, to, vr, cs));
743
+ select({ r: Math.min(g.r0, to), c: g.c0 }, { r: Math.max(g.r1, to), c: g.c1 });
744
+ }
745
+ }
746
+ }
747
+ window.addEventListener("pointermove", move);
748
+ window.addEventListener("pointerup", up);
749
+ window.addEventListener("pointercancel", up);
750
+ return () => {
751
+ window.removeEventListener("pointermove", move);
752
+ window.removeEventListener("pointerup", up);
753
+ window.removeEventListener("pointercancel", up);
754
+ };
755
+ // eslint-disable-next-line react-hooks/exhaustive-deps
756
+ }, [select, commitChanges, headH, rowH, viewRows.length, gutterW, frozenW]);
757
+
758
+ function onPointerDown(e) {
759
+ lastPointer.current = e.pointerType || "mouse";
760
+ const t = e.target;
761
+ if (t.closest?.("[data-fill]")) {
762
+ e.preventDefault();
763
+ e.stopPropagation();
764
+ drag.current = { mode: "fill" };
765
+ setFillTo(range?.r1 ?? null);
766
+ return;
767
+ }
768
+ const gutter = t.closest?.("[data-gutter]");
769
+ if (gutter && !t.closest("[data-expand]")) {
770
+ const r = Number(gutter.dataset.r);
771
+ if (e.button !== 0 && inRange(range ?? { r0: -1, r1: -1, c0: 0, c1: 0 }, r, 0)) return;
772
+ e.preventDefault();
773
+ focusGrid();
774
+ const anchor = e.shiftKey && range ? range.r0 : r;
775
+ select({ r: anchor, c: 0 }, { r, c: cols.length - 1 }, { rows: true });
776
+ if (e.button === 0 && e.pointerType === "mouse") drag.current = { mode: "rows", anchor };
777
+ return;
778
+ }
779
+ const hit = cellFrom(t);
780
+ if (!hit) return;
781
+ const ed = editingRef.current;
782
+ if (ed && ed.id === viewRows[hit.r]?.id && ed.key === cols[hit.c]?.key) return;
783
+ const secondary = e.button === 2 || (e.button === 0 && e.ctrlKey && isMac());
784
+ if (secondary) {
785
+ if (!range || !inRange(range, hit.r, hit.c)) select(hit);
786
+ return;
787
+ }
788
+ if (e.button !== 0) return;
789
+ if (t.closest?.("[data-toggle]") && editable(hit.c)) {
790
+ e.preventDefault();
791
+ focusGrid();
792
+ select(hit);
793
+ const row = viewRows[hit.r];
794
+ const key = cols[hit.c].key;
795
+ commitChanges([{ rowId: row.id, key, value: !row[key], prev: row[key] }]);
796
+ return;
797
+ }
798
+ if (t.closest?.("input, button, a, [role=radio], label")) return; // a rating star, a link
799
+ if (e.pointerType !== "mouse") return; // touch: a tap is handled on click, a drag scrolls
800
+ e.preventDefault();
801
+ focusGrid();
802
+ if (e.shiftKey && A) select(A, hit);
803
+ else select(hit);
804
+ drag.current = { mode: "select", anchor: e.shiftKey && A ? A : hit, last: hit };
805
+ }
806
+
807
+ const isEditingAt = (hit) => {
808
+ const ed = editingRef.current;
809
+ return Boolean(ed && ed.id === viewRows[hit.r]?.id && ed.key === cols[hit.c]?.key);
810
+ };
811
+
812
+ function onClick(e) {
813
+ if (lastPointer.current === "mouse") return;
814
+ const hit = cellFrom(e.target);
815
+ if (!hit || isEditingAt(hit) || e.target.closest?.("input, button, a, [role=radio], label, [data-toggle]")) return;
816
+ focusGrid();
817
+ if (A && single && A.r === hit.r && A.c === hit.c) startEdit(hit.r, hit.c);
818
+ else select(hit);
819
+ }
820
+
821
+ function onDoubleClick(e) {
822
+ const hit = cellFrom(e.target);
823
+ if (!hit || isEditingAt(hit) || e.target.closest?.("[data-toggle], a, [role=radio]")) return;
824
+ startEdit(hit.r, hit.c);
825
+ }
826
+
827
+ // ── column header actions ────────────────────────────────────────────
828
+ const [renaming, setRenaming] = useState(null);
829
+ const [colDrag, setColDrag] = useState(null); // { from, to }
830
+ const [filterOpen, setFilterOpen] = useState(false);
831
+ const [addOpen, setAddOpen] = useState(false);
832
+
833
+ function reorder(fromKey, toIndex) {
834
+ const all = orderedColumns(colsRef.current, viewRef.current).map((c) => c.key);
835
+ const shown = cols.map((c) => c.key);
836
+ const targetKey = shown[toIndex] ?? null; // insert before this one (null: at the end)
837
+ const without = all.filter((k) => k !== fromKey);
838
+ const at = targetKey ? without.indexOf(targetKey) : without.length;
839
+ without.splice(at < 0 ? without.length : at, 0, fromKey);
840
+ setView({ order: without });
841
+ }
842
+
843
+ function onHeadPointerDown(e, c) {
844
+ if (e.button !== 0 || e.pointerType !== "mouse" || e.target.closest("button, input, [data-resize]")) return;
845
+ e.preventDefault();
846
+ const x0 = e.clientX;
847
+ let moved = false;
848
+ const el = scroller.current;
849
+ const target = (clientX) => {
850
+ const rect = el.getBoundingClientRect();
851
+ const x = clientX - rect.left + el.scrollLeft;
852
+ let i = 0;
853
+ while (i < cols.length && colLeft[i] + cols[i].width / 2 < x) i++;
854
+ return i;
855
+ };
856
+ const move = (ev) => {
857
+ if (!moved && Math.abs(ev.clientX - x0) < 5) return;
858
+ moved = true;
859
+ autoScroll(ev);
860
+ setColDrag({ from: c, to: target(ev.clientX) });
861
+ };
862
+ const up = (ev) => {
863
+ window.removeEventListener("pointermove", move);
864
+ window.removeEventListener("pointerup", up);
865
+ window.removeEventListener("pointercancel", up);
866
+ if (ev.type === "pointercancel") return setColDrag(null);
867
+ if (moved) {
868
+ const to = target(ev.clientX);
869
+ setColDrag(null);
870
+ if (to !== c && to !== c + 1) reorder(cols[c].key, to);
871
+ } else {
872
+ focusGrid();
873
+ if (!viewRows.length) return;
874
+ if (ev.shiftKey && A) select({ r: 0, c: A.c }, { r: viewRows.length - 1, c });
875
+ else select({ r: 0, c }, { r: viewRows.length - 1, c });
876
+ }
877
+ };
878
+ window.addEventListener("pointermove", move);
879
+ window.addEventListener("pointerup", up);
880
+ window.addEventListener("pointercancel", up);
881
+ }
882
+
883
+ function onResizeDown(e, col) {
884
+ e.preventDefault();
885
+ e.stopPropagation();
886
+ const x0 = e.clientX;
887
+ const w0 = col.width;
888
+ let last = w0;
889
+ e.currentTarget.setPointerCapture?.(e.pointerId);
890
+ const move = (ev) => {
891
+ last = clamp(Math.round(w0 + ev.clientX - x0), MIN_W, MAX_W);
892
+ setResizing({ key: col.key, width: last });
893
+ };
894
+ const up = () => {
895
+ window.removeEventListener("pointermove", move);
896
+ window.removeEventListener("pointerup", up);
897
+ window.removeEventListener("pointercancel", up);
898
+ setResizing(null);
899
+ if (last !== w0) setView((v) => ({ widths: { ...v.widths, [col.key]: last } }));
900
+ };
901
+ window.addEventListener("pointermove", move);
902
+ window.addEventListener("pointerup", up);
903
+ window.addEventListener("pointercancel", up);
904
+ }
905
+
906
+ async function changeType(col, typeName) {
907
+ if (col.type === typeName) return;
908
+ const before = colsRef.current;
909
+ let next = { ...col, type: typeName };
910
+ const t = typeOf(next, custom);
911
+ const changes = [];
912
+ const creates = [];
913
+ for (const r of localRef.current) {
914
+ const v = r[col.key];
915
+ if (typeOf(col, custom).empty(v) && t.name !== "checkbox") continue;
916
+ if (t.editor === "select") {
917
+ const res = parseCell(typeOf(col, custom).toText(v, col, ctx), { ...next, options: [...(next.options ?? []), ...creates] }, ctx, custom);
918
+ if ("error" in res) continue;
919
+ if (res.create) creates.push(...res.create);
920
+ changes.push({ rowId: r.id, key: col.key, value: res.value, prev: v });
921
+ } else changes.push({ rowId: r.id, key: col.key, value: convertValue(v, col, next, ctx, custom), prev: v });
922
+ }
923
+ if (creates.length) next = { ...next, options: [...(next.options ?? []), ...creates] };
924
+ const res = await updateColumns(
925
+ before.map((c) => (c.key === col.key ? next : c)),
926
+ "type",
927
+ );
928
+ if (!res.ok) return;
929
+ await commitChanges(changes, { history: false });
930
+ toast(`${col.name} is now ${t.label.toLowerCase()}`, {
931
+ action: {
932
+ label: "Undo",
933
+ onClick: () => {
934
+ updateColumns(before, "type");
935
+ commitChanges(invertChanges(realChanges(changes)), { history: false });
936
+ },
937
+ },
938
+ });
939
+ }
940
+
941
+ function insertColumn(c, side) {
942
+ const names = new Set(colsRef.current.map((x) => x.name));
943
+ let n = colsRef.current.length + 1;
944
+ while (names.has(`Field ${n}`)) n++;
945
+ const col = { key: uid("field"), name: `Field ${n}`, type: "text" };
946
+ addColumn(col, side === "left" ? c : c + 1);
947
+ setRenaming(col.key);
948
+ }
949
+
950
+ function addColumn(col, visibleIndex = cols.length) {
951
+ const all = colsRef.current;
952
+ updateColumns([...all, col], "add");
953
+ const order = orderedColumns(all, viewRef.current).map((x) => x.key);
954
+ const beforeKey = cols[visibleIndex]?.key;
955
+ const i = beforeKey ? order.indexOf(beforeKey) : order.length;
956
+ order.splice(i < 0 ? order.length : i, 0, col.key);
957
+ setView({ order });
958
+ }
959
+
960
+ function deleteColumn(col) {
961
+ const before = colsRef.current;
962
+ updateColumns(
963
+ before.filter((c) => c.key !== col.key),
964
+ "delete",
965
+ );
966
+ toast(`Deleted ${col.name}`, { action: { label: "Undo", onClick: () => updateColumns(before, "restore") } });
967
+ }
968
+
969
+ function headerItems(col, c) {
970
+ const t = typeOf(col, custom);
971
+ const words = sortWords(t);
972
+ const sorted = view.sort.find((s) => s.key === col.key);
973
+ return [
974
+ { label: `Sort ${words.asc}`, icon: "arrow-up", onSelect: () => setView({ sort: [{ key: col.key, dir: "asc" }] }) },
975
+ { label: `Sort ${words.desc}`, icon: "arrow-down", onSelect: () => setView({ sort: [{ key: col.key, dir: "desc" }] }) },
976
+ sorted && { label: "Remove sort", icon: "x", onSelect: () => setView((v) => ({ sort: v.sort.filter((s) => s.key !== col.key) })) },
977
+ {
978
+ label: "Filter by this field",
979
+ icon: "filter",
980
+ onSelect: () => {
981
+ setView((v) => ({ filters: [...v.filters, { id: uid("f"), key: col.key, op: t.allFilters[0].id, value: "" }] }));
982
+ setFilterOpen(true);
983
+ },
984
+ },
985
+ { type: "separator" },
986
+ canEditCols && { label: "Rename", icon: "pencil", onSelect: () => setRenaming(col.key) },
987
+ canEditCols &&
988
+ !col.computed && {
989
+ label: "Field type",
990
+ icon: t.icon,
991
+ items: [
992
+ {
993
+ type: "radio",
994
+ value: col.type ?? "text",
995
+ onValueChange: (v) => changeType(col, v),
996
+ options: [...TYPE_ORDER, ...Object.keys(custom ?? {}).filter((k) => !TYPE_ORDER.includes(k))].map((k) => ({ value: k, label: typeOf({ type: k }, custom).label })),
997
+ },
998
+ ],
999
+ },
1000
+ canEditCols && { label: "Insert left", icon: "arrow-left", onSelect: () => insertColumn(c, "left") },
1001
+ canEditCols && { label: "Insert right", icon: "arrow-right", onSelect: () => insertColumn(c, "right") },
1002
+ canEditCols && { type: "separator" },
1003
+ c < frozenN
1004
+ ? { label: c === 0 && frozenN === 1 ? "Unfreeze" : "Freeze up to here", icon: "pin", onSelect: () => setView({ frozen: c === 0 && frozenN === 1 ? 0 : c + 1 }) }
1005
+ : { label: "Freeze up to here", icon: "pin", onSelect: () => setView({ frozen: c + 1 }) },
1006
+ cols.length > 1 && { label: "Hide", icon: "eye-off", onSelect: () => setView((v) => ({ hidden: [...v.hidden, col.key] })) },
1007
+ canEditCols && cols.length > 1 && { label: "Delete field", icon: "trash", tone: "danger", onSelect: () => deleteColumn(col) },
1008
+ ];
1009
+ }
1010
+
1011
+ // ── the context menu (right-click, long press, Shift+F10) ─────────────
1012
+ const menuItems = (() => {
1013
+ if (!range || !viewRows.length) return [];
1014
+ const ids = viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id);
1015
+ const n = ids.length;
1016
+ const row = viewRows[range.r0];
1017
+ const rowAt = local.findIndex((x) => x.id === row?.id);
1018
+ return [
1019
+ {
1020
+ label: "Copy",
1021
+ icon: "copy",
1022
+ shortcut: "⌘C",
1023
+ onSelect: () => navigator.clipboard?.writeText(copyText()).then(() => toast.success("Copied"), () => toast.error("Couldn’t reach the clipboard — use ⌘C")),
1024
+ },
1025
+ !readOnly && { label: "Clear", icon: "eraser", shortcut: "⌫", onSelect: () => commitChanges(clearChanges(range, viewRows, cols, ctx, custom)) },
1026
+ !readOnly && range.r1 > range.r0 && { label: "Fill down", icon: "arrow-down", shortcut: "⌘D", onSelect: () => commitChanges(fillChanges({ ...range, r1: range.r0 }, range.r1, viewRows, cols)) },
1027
+ onExpandRow && n === 1 && { label: `Open ${noun}`, icon: "maximize", shortcut: "Space", onSelect: () => onExpandRow(row) },
1028
+ canAdd && { type: "separator" },
1029
+ canAdd && { label: `Insert ${noun} above`, icon: "arrow-up", onSelect: () => addRows([newRow()], { index: rowAt, hint: { ref: row.id, before: true } }) },
1030
+ canAdd && { label: `Insert ${noun} below`, icon: "arrow-down", onSelect: () => addRows([newRow()], { index: rowAt + 1, hint: { ref: row.id } }) },
1031
+ onDeleteRows && !readOnly && { type: "separator" },
1032
+ onDeleteRows && !readOnly && { label: n === 1 ? `Delete ${noun}` : `Delete ${plural(n, noun)}`, icon: "trash", tone: "danger", onSelect: () => deleteRows(ids) },
1033
+ ];
1034
+ })();
1035
+
1036
+ // ── virtualisers ─────────────────────────────────────────────────────
1037
+ const activeRow = useRef(-1);
1038
+ activeRow.current = A ? A.r : -1;
1039
+ const activeCol = useRef(-1);
1040
+ activeCol.current = A ? A.c - frozenN : -1;
1041
+ const keepRow = useCallback((range) => {
1042
+ const out = defaultRangeExtractor(range);
1043
+ const a = activeRow.current;
1044
+ if (a >= 0 && a < range.count && !out.includes(a)) {
1045
+ out.push(a);
1046
+ out.sort((x, y) => x - y);
1047
+ }
1048
+ return out;
1049
+ }, []);
1050
+ const keepCol = useCallback((range) => {
1051
+ const out = defaultRangeExtractor(range);
1052
+ const a = activeCol.current;
1053
+ if (a >= 0 && a < range.count && !out.includes(a)) {
1054
+ out.push(a);
1055
+ out.sort((x, y) => x - y);
1056
+ }
1057
+ return out;
1058
+ }, []);
1059
+
1060
+ const shownRows = loading ? 0 : viewRows.length;
1061
+ const rowV = useVirtualizer({
1062
+ count: shownRows,
1063
+ getScrollElement: () => scroller.current,
1064
+ estimateSize: () => rowH,
1065
+ overscan: 10,
1066
+ scrollMargin: headH,
1067
+ initialRect: { width: 1200, height: typeof height === "number" ? height : 560 },
1068
+ rangeExtractor: keepRow,
1069
+ getItemKey: (i) => viewRows[i]?.id ?? i,
1070
+ });
1071
+ const scrollCols = cols.slice(frozenN);
1072
+ const colV = useVirtualizer({
1073
+ horizontal: true,
1074
+ count: scrollCols.length,
1075
+ getScrollElement: () => scroller.current,
1076
+ estimateSize: (i) => scrollCols[i]?.width ?? 160,
1077
+ overscan: 2,
1078
+ scrollMargin: gutterW + frozenW,
1079
+ initialRect: { width: 1200, height: typeof height === "number" ? height : 560 },
1080
+ rangeExtractor: keepCol,
1081
+ getItemKey: (i) => scrollCols[i]?.key ?? i,
1082
+ });
1083
+ const widthsSig = `${gutterW}|${frozenW}|${scrollCols.map((c) => c.width).join(",")}`;
1084
+ useLayoutEffect(() => {
1085
+ colV.measure();
1086
+ }, [widthsSig, colV]);
1087
+ useLayoutEffect(() => {
1088
+ rowV.measure();
1089
+ }, [rowH, headH, rowV]);
1090
+
1091
+ const vRows = rowV.getVirtualItems();
1092
+ const vCols = colV.getVirtualItems();
1093
+ const bodyH = shownRows * rowH;
1094
+ const addLine = canAdd && !loading;
1095
+ const contentH = headH + (loading ? 8 * rowH : bodyH) + (addLine ? rowH : 0);
1096
+
1097
+ // The imperative handle: for a parent that drives the grid (base-view).
1098
+ useImperativeHandle(
1099
+ ref,
1100
+ () => ({
1101
+ focus: focusGrid,
1102
+ select: ({ rowId, key, toRowId, toKey } = {}) => {
1103
+ const r = rowIndex.get(rowId);
1104
+ const c = key == null ? 0 : colIndex.get(key);
1105
+ if (r == null || c == null) return false;
1106
+ const r2 = toRowId != null ? rowIndex.get(toRowId) ?? r : r;
1107
+ const c2 = toKey != null ? colIndex.get(toKey) ?? c : c;
1108
+ select({ r, c }, { r: r2, c: c2 });
1109
+ ensureVisible(r, c);
1110
+ return true;
1111
+ },
1112
+ getSelection: () =>
1113
+ range
1114
+ ? {
1115
+ active: { rowId: viewRows[A.r]?.id, key: cols[A.c]?.key },
1116
+ rowIds: viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id),
1117
+ keys: cols.slice(range.c0, range.c1 + 1).map((c) => c.key),
1118
+ }
1119
+ : null,
1120
+ clearSelection: () => setSel(null),
1121
+ scrollToRow: (rowId) => {
1122
+ const r = rowIndex.get(rowId);
1123
+ if (r == null) return false;
1124
+ ensureVisible(r, null);
1125
+ return true;
1126
+ },
1127
+ edit: (rowId, key) => {
1128
+ const r = rowIndex.get(rowId);
1129
+ const c = colIndex.get(key);
1130
+ if (r == null || c == null) return false;
1131
+ startEdit(r, c);
1132
+ return true;
1133
+ },
1134
+ addRow: (values) => addRows([newRow(values)], { edit: true }),
1135
+ deleteRows: (ids) => deleteRows(ids),
1136
+ undo,
1137
+ redo,
1138
+ copy: copyText,
1139
+ paste: pasteText,
1140
+ visibleRows: () => viewRows,
1141
+ visibleColumns: () => cols,
1142
+ resort: () => setResortTick((n) => n + 1),
1143
+ }),
1144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1145
+ [rowIndex, colIndex, range, viewRows, cols, A],
1146
+ );
1147
+
1148
+ // ── drawing ──────────────────────────────────────────────────────────
1149
+ const fillRange = fillTo != null && range && (fillTo > range.r1 || fillTo < range.r0) ? { r0: Math.min(fillTo, range.r0), r1: Math.max(fillTo, range.r1), c0: range.c0, c1: range.c1 } : null;
1150
+ const activeId = A ? `${gid}-c-${A.r}-${A.c}` : undefined;
1151
+
1152
+ function cellShadow(r, c) {
1153
+ if (!range || !inRange(range, r, c)) return undefined;
1154
+ const P = "var(--color-primary)";
1155
+ if (A && A.r === r && A.c === c) return `inset 0 0 0 2px ${P}`;
1156
+ const s = [];
1157
+ if (r === range.r0) s.push(`inset 0 1px 0 0 ${P}`);
1158
+ if (r === range.r1) s.push(`inset 0 -1px 0 0 ${P}`);
1159
+ if (c === range.c0) s.push(`inset 1px 0 0 0 ${P}`);
1160
+ if (c === range.c1) s.push(`inset -1px 0 0 0 ${P}`);
1161
+ return s.join(", ") || undefined;
1162
+ }
1163
+
1164
+ function renderCell(row, r, col, c, sticky) {
1165
+ const t = typeOf(col, custom);
1166
+ const isActive = A && A.r === r && A.c === c;
1167
+ const inSel = range && inRange(range, r, c);
1168
+ const isEditing = editing && editing.id === row.id && editing.key === col.key;
1169
+ const problem = problems.get(`${row.id}\u0000${col.key}`);
1170
+ const Editor = isEditing ? editorFor(t) : null;
1171
+ const corner = range && !editing && !readOnly && r === range.r1 && c === range.c1 && lastPointer.current === "mouse";
1172
+ const filling = fillRange && inRange(fillRange, r, c) && !inRange(range, r, c);
1173
+ return (
1174
+ <div
1175
+ key={col.key}
1176
+ id={`${gid}-c-${r}-${c}`}
1177
+ role="gridcell"
1178
+ aria-colindex={c + 2}
1179
+ aria-selected={inSel ? true : undefined}
1180
+ aria-readonly={!editable(c) || undefined}
1181
+ aria-invalid={problem ? true : undefined}
1182
+ title={problem || undefined}
1183
+ data-cell=""
1184
+ data-r={r}
1185
+ data-c={c}
1186
+ className={cx(
1187
+ "flex h-full shrink-0 items-center border-b border-r border-border bg-background px-2.5 text-sm text-foreground",
1188
+ sticky ? "sticky z-10" : "absolute top-0",
1189
+ sticky && c === frozenN - 1 && "border-r-input",
1190
+ isActive && !sticky && "z-[2]",
1191
+ isEditing && "z-20",
1192
+ t.align === "end" && "justify-end",
1193
+ )}
1194
+ style={{ width: col.width, left: sticky ? colLeft[c] : colLeft[c] }}
1195
+ >
1196
+ <span
1197
+ aria-hidden="true"
1198
+ className={cx(
1199
+ "pointer-events-none absolute inset-0",
1200
+ inSel ? (single ? "" : "bg-primary/8") : filling ? "bg-primary/5" : "group-hover/row:bg-muted/60",
1201
+ rowsMode && inSel && "bg-primary/8",
1202
+ )}
1203
+ style={{ boxShadow: cellShadow(r, c) }}
1204
+ />
1205
+ <span className={cx("relative flex h-full min-w-0 flex-1 items-center", t.align === "end" && "justify-end")}>
1206
+ {Editor ? (
1207
+ <Editor
1208
+ value={row[col.key]}
1209
+ column={col}
1210
+ type={t}
1211
+ ctx={ctx}
1212
+ initial={editing.initial}
1213
+ today={today}
1214
+ canCreate={!readOnly}
1215
+ onCommit={commitEdit}
1216
+ onCancel={cancelEdit}
1217
+ />
1218
+ ) : (
1219
+ <CellValue
1220
+ value={row[col.key]}
1221
+ column={col}
1222
+ type={t}
1223
+ ctx={ctx}
1224
+ active={isActive}
1225
+ readOnly={!editable(c)}
1226
+ onSet={(v) => {
1227
+ commitChanges([{ rowId: row.id, key: col.key, value: v, prev: row[col.key] }]);
1228
+ focusGrid();
1229
+ }}
1230
+ />
1231
+ )}
1232
+ </span>
1233
+ {problem ? <span aria-hidden="true" className="pointer-events-none absolute right-0 top-0 size-0 border-l-[7px] border-t-[7px] border-l-transparent border-t-destructive" /> : null}
1234
+ {corner ? (
1235
+ <span
1236
+ data-fill=""
1237
+ aria-hidden="true"
1238
+ title="Drag to fill"
1239
+ className="absolute -bottom-[4px] -right-[4px] z-30 size-[9px] cursor-crosshair rounded-[2px] border-2 border-background bg-primary"
1240
+ />
1241
+ ) : null}
1242
+ </div>
1243
+ );
1244
+ }
1245
+
1246
+ function renderRow(vr) {
1247
+ const r = vr.index;
1248
+ const row = viewRows[r];
1249
+ if (!row) return null;
1250
+ const rowSelected = rowsMode && range && r >= range.r0 && r <= range.r1;
1251
+ const rowActive = A && A.r === r;
1252
+ return (
1253
+ <div
1254
+ key={row.id}
1255
+ role="row"
1256
+ aria-rowindex={r + 2}
1257
+ aria-selected={rowSelected || undefined}
1258
+ className="group/row absolute left-0 top-0 flex"
1259
+ style={{ height: rowH, width: totalW, transform: `translateY(${vr.start}px)` }}
1260
+ >
1261
+ <div
1262
+ role="rowheader"
1263
+ data-gutter=""
1264
+ data-r={r}
1265
+ className={cx(
1266
+ "sticky left-0 z-10 flex h-full shrink-0 cursor-default items-center justify-between border-b border-r border-border bg-background pl-2.5 pr-1 text-xs tabular-nums",
1267
+ rowSelected ? "text-primary" : rowActive ? "text-foreground" : "text-muted-foreground",
1268
+ )}
1269
+ style={{ width: gutterW }}
1270
+ >
1271
+ <span aria-hidden="true" className={cx("pointer-events-none absolute inset-0", rowSelected ? "bg-primary/8" : "group-hover/row:bg-muted/60")} />
1272
+ <span className="relative">{r + 1}</span>
1273
+ {onExpandRow && !narrow ? (
1274
+ // raw: a 24px open-row glyph inside the 64px gutter, shown on the row's hover; IconButton's smallest square is 36px.
1275
+ <button
1276
+ type="button"
1277
+ data-expand=""
1278
+ tabIndex={-1}
1279
+ aria-label={`Open ${noun} ${r + 1}`}
1280
+ title={`Open ${noun}`}
1281
+ onClick={() => onExpandRow(row)}
1282
+ className="relative grid size-6 place-items-center rounded-sm text-muted-foreground opacity-0 transition-opacity duration-150 hover:bg-accent hover:text-foreground group-hover/row:opacity-100 focus-visible:opacity-100"
1283
+ >
1284
+ <Icon name="maximize" size={13} />
1285
+ </button>
1286
+ ) : null}
1287
+ </div>
1288
+ {cols.slice(0, frozenN).map((col, c) => renderCell(row, r, col, c, true))}
1289
+ {vCols.map((vc) => renderCell(row, r, scrollCols[vc.index], vc.index + frozenN, false))}
1290
+ </div>
1291
+ );
1292
+ }
1293
+
1294
+ function renderHeadCell(col, c, sticky) {
1295
+ const t = typeOf(col, custom);
1296
+ const sortAt = view.sort.findIndex((s) => s.key === col.key);
1297
+ const sortDir = sortAt >= 0 ? view.sort[sortAt].dir : null;
1298
+ const isFiltered = view.filters.some((f) => f.key === col.key);
1299
+ const colSelected = range && range.r0 === 0 && range.r1 === viewRows.length - 1 && c >= range.c0 && c <= range.c1 && viewRows.length > 1;
1300
+ const dragging = colDrag && colDrag.from === c;
1301
+ return (
1302
+ <div
1303
+ key={col.key}
1304
+ role="columnheader"
1305
+ aria-colindex={c + 2}
1306
+ aria-sort={sortDir === "asc" ? "ascending" : sortDir === "desc" ? "descending" : undefined}
1307
+ className={cx(
1308
+ "group/h flex h-full shrink-0 items-center gap-1 border-b border-r border-border bg-muted pl-2.5 pr-1 text-xs font-medium text-foreground select-none",
1309
+ sticky ? "sticky z-10" : "absolute top-0",
1310
+ sticky && c === frozenN - 1 && "border-r-input",
1311
+ dragging && "opacity-50",
1312
+ )}
1313
+ style={{ width: col.width, left: colLeft[c] }}
1314
+ >
1315
+ {colSelected ? <span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-primary/10" /> : null}
1316
+ <span className="relative flex min-w-0 flex-1 cursor-default items-center gap-1.5 self-stretch" onPointerDown={(e) => onHeadPointerDown(e, c)}>
1317
+ <Icon name={t.icon} size={14} className="shrink-0 text-muted-foreground" />
1318
+ {renaming === col.key ? (
1319
+ <RenameInput
1320
+ name={col.name}
1321
+ onDone={(name) => {
1322
+ setRenaming(null);
1323
+ focusGrid();
1324
+ if (name && name !== col.name) updateColumns(colsRef.current.map((x) => (x.key === col.key ? { ...x, name } : x)), "rename");
1325
+ }}
1326
+ />
1327
+ ) : (
1328
+ <span className="truncate">{col.name}</span>
1329
+ )}
1330
+ {col.readOnly || col.computed ? <Icon name="lock" size={12} className="shrink-0 text-muted-foreground" label="Read only" /> : null}
1331
+ {col.required ? (
1332
+ <span aria-label="Required" className="text-destructive">
1333
+ *
1334
+ </span>
1335
+ ) : null}
1336
+ </span>
1337
+ {sortDir ? (
1338
+ <span className="relative flex shrink-0 items-center text-primary" title={`Sorted ${sortWords(t)[sortDir]}`}>
1339
+ <Icon name={sortDir === "asc" ? "arrow-up" : "arrow-down"} size={13} />
1340
+ {view.sort.length > 1 ? <span className="text-2xs tabular-nums">{sortAt + 1}</span> : null}
1341
+ </span>
1342
+ ) : null}
1343
+ {isFiltered ? <span aria-label="Filtered" title="Filtered" className="relative size-1.5 shrink-0 rounded-full bg-primary" /> : null}
1344
+ <span className={cx("relative shrink-0", !narrow && !coarse && "opacity-0 transition-opacity duration-150 group-hover/h:opacity-100 focus-within:opacity-100 has-[[aria-expanded=true]]:opacity-100")}>
1345
+ <Menu items={headerItems(col, c)} label={`${col.name}, field menu`} trigger={<Icon name="chevron-down" size={14} />} align="start" />
1346
+ </span>
1347
+ <span
1348
+ data-resize=""
1349
+ role="separator"
1350
+ aria-orientation="vertical"
1351
+ aria-label={`Resize ${col.name}`}
1352
+ aria-valuenow={col.width}
1353
+ aria-valuemin={MIN_W}
1354
+ aria-valuemax={MAX_W}
1355
+ tabIndex={0}
1356
+ onPointerDown={(e) => onResizeDown(e, col)}
1357
+ onDoubleClick={() =>
1358
+ setView((v) => {
1359
+ const widths = { ...v.widths };
1360
+ delete widths[col.key];
1361
+ return { widths };
1362
+ })
1363
+ }
1364
+ onKeyDown={(e) => {
1365
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
1366
+ e.preventDefault();
1367
+ const w = clamp(col.width + (e.key === "ArrowRight" ? 16 : -16), MIN_W, MAX_W);
1368
+ setView((v) => ({ widths: { ...v.widths, [col.key]: w } }));
1369
+ }}
1370
+ className="absolute -right-1 top-0 z-20 h-full w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-2 after:left-[3px] after:w-0.5 after:rounded-full after:bg-primary after:opacity-0 after:transition-opacity hover:after:opacity-100 focus-visible:after:opacity-100"
1371
+ />
1372
+ </div>
1373
+ );
1374
+ }
1375
+
1376
+ const header = (
1377
+ <div role="row" aria-rowindex={1} className="sticky top-0 z-30 flex" style={{ height: headH, width: totalW }}>
1378
+ <div className="sticky left-0 z-10 flex h-full shrink-0 items-center border-b border-r border-border bg-muted pl-2.5 text-xs text-muted-foreground" style={{ width: gutterW }}>
1379
+ <span className="sr-only">Row</span>
1380
+ <span aria-hidden="true">#</span>
1381
+ </div>
1382
+ {cols.slice(0, frozenN).map((col, c) => renderHeadCell(col, c, true))}
1383
+ {vCols.map((vc) => renderHeadCell(scrollCols[vc.index], vc.index + frozenN, false))}
1384
+ {canEditCols ? (
1385
+ <div className="absolute top-0 flex h-full items-center justify-center border-b border-border bg-muted" style={{ left: colLeft[cols.length], width: ADD_COL }}>
1386
+ <Popover open={addOpen} onOpenChange={setAddOpen} trigger={<Icon name="plus" size={16} />} variant="quiet" size="sm" width="auto" align="end" label="Add a field">
1387
+ <AddFieldPanel
1388
+ names={localCols.map((x) => x.name)}
1389
+ custom={custom}
1390
+ onAdd={(f) => {
1391
+ const col = { key: uid("field"), ...f };
1392
+ addColumn(col);
1393
+ setAddOpen(false);
1394
+ // Show the new field: it is the last one.
1395
+ requestAnimationFrame(() => {
1396
+ if (scroller.current) scroller.current.scrollLeft = scroller.current.scrollWidth;
1397
+ });
1398
+ }}
1399
+ />
1400
+ </Popover>
1401
+ </div>
1402
+ ) : null}
1403
+ {colDrag ? (
1404
+ <span aria-hidden="true" className="pointer-events-none absolute top-0 z-40 w-0.5 bg-primary" style={{ left: colLeft[colDrag.to] - 1, height: contentH }} />
1405
+ ) : null}
1406
+ </div>
1407
+ );
1408
+
1409
+ // ── the body when there is nothing to show ───────────────────────────
1410
+ let emptyBody = null;
1411
+ if (!loading && viewRows.length === 0) {
1412
+ const blank = local.length === 0;
1413
+ emptyBody =
1414
+ blank && empty ? (
1415
+ empty
1416
+ ) : (
1417
+ <div className="grid max-w-sm justify-items-center gap-2 text-center">
1418
+ <span className="grid size-10 place-items-center rounded-full bg-muted text-muted-foreground">
1419
+ <Icon name={blank ? "grid" : "filter"} size={18} />
1420
+ </span>
1421
+ <p className="text-sm font-medium">{blank ? (cols.length ? `No ${noun}s yet` : "No fields yet") : `No ${noun}s match`}</p>
1422
+ <p className="text-sm text-muted-foreground">
1423
+ {blank
1424
+ ? cols.length
1425
+ ? canAdd
1426
+ ? `Add a ${noun}, or click here and paste rows copied from a spreadsheet.`
1427
+ : `When there are ${noun}s, they show here.`
1428
+ : "Add a field to start the table."
1429
+ : "Try another search, or clear the filters to see everything."}
1430
+ </p>
1431
+ <div className="mt-2">
1432
+ {blank ? (
1433
+ canAdd && cols.length ? (
1434
+ <Button variant="primary" icon="plus" onClick={() => addRows([newRow()], { edit: true })}>
1435
+ Add a {noun}
1436
+ </Button>
1437
+ ) : null
1438
+ ) : (
1439
+ <Button
1440
+ onClick={() => {
1441
+ setSearch("");
1442
+ setView({ filters: [] });
1443
+ }}
1444
+ >
1445
+ Clear filters
1446
+ </Button>
1447
+ )}
1448
+ </div>
1449
+ </div>
1450
+ );
1451
+ }
1452
+
1453
+ // ── status line ──────────────────────────────────────────────────────
1454
+ const summary = range && !single ? summarize(range, viewRows, cols) : null;
1455
+ const activeProblem = A ? problems.get(`${viewRows[A.r]?.id}\u0000${cols[A.c]?.key}`) : null;
1456
+ function nextProblem() {
1457
+ for (let r = 0; r < viewRows.length; r++) {
1458
+ for (let c = 0; c < cols.length; c++) {
1459
+ if (problems.has(`${viewRows[r].id}\u0000${cols[c].key}`) && (!A || r > A.r || (r === A.r && c > A.c))) {
1460
+ select({ r, c });
1461
+ ensureVisible(r, c);
1462
+ focusGrid();
1463
+ return;
1464
+ }
1465
+ }
1466
+ }
1467
+ // wrap to the first
1468
+ for (let r = 0; r < viewRows.length; r++)
1469
+ for (let c = 0; c < cols.length; c++)
1470
+ if (problems.has(`${viewRows[r].id}\u0000${cols[c].key}`)) {
1471
+ select({ r, c });
1472
+ ensureVisible(r, c);
1473
+ focusGrid();
1474
+ return;
1475
+ }
1476
+ }
1477
+ const fmtSum = (n) => (summary?.numCol ? typeOf(summary.numCol, custom).format(n, summary.numCol, ctx) : n.toLocaleString(locale, { maximumFractionDigits: 2 }));
1478
+
1479
+ const btnSize = "md";
1480
+ const hiddenCount = view.hidden.filter((k) => localCols.some((c) => c.key === k)).length;
1481
+ const filterCount = view.filters.length;
1482
+
1483
+ return (
1484
+ <div ref={rootRef} className={cx("grid min-w-0 gap-3", className)}>
1485
+ {toolbar ? (
1486
+ <div className="flex flex-wrap items-center gap-2">
1487
+ {toolbarStart}
1488
+ <div className="min-w-0 flex-1 max-sm:basis-full sm:max-w-72">
1489
+ <InputGroup
1490
+ icon="search"
1491
+ placeholder={`Search ${noun}s`}
1492
+ aria-label={`Search ${noun}s`}
1493
+ value={search}
1494
+ onChange={(e) => setSearch(e.target.value)}
1495
+ action={search ? { label: "Clear search", icon: "x", onClick: () => setSearch("") } : undefined}
1496
+ />
1497
+ </div>
1498
+ <Popover
1499
+ open={filterOpen}
1500
+ onOpenChange={setFilterOpen}
1501
+ variant={filterCount ? "secondary" : "quiet"}
1502
+ size={btnSize}
1503
+ width="auto"
1504
+ align="start"
1505
+ label="Filter"
1506
+ trigger={
1507
+ <>
1508
+ <Icon name="filter" />
1509
+ Filter
1510
+ {filterCount ? (
1511
+ <Badge variant="soft" tone="primary" className="tabular-nums">
1512
+ {filterCount}
1513
+ </Badge>
1514
+ ) : null}
1515
+ </>
1516
+ }
1517
+ >
1518
+ <FilterPanel columns={orderedColumns(localCols, view)} filters={view.filters} ctx={ctx} custom={custom} onChange={(filters) => setView({ filters })} />
1519
+ </Popover>
1520
+ <Popover
1521
+ variant={view.sort.length ? "secondary" : "quiet"}
1522
+ size={btnSize}
1523
+ width="auto"
1524
+ align="start"
1525
+ label="Sort"
1526
+ trigger={
1527
+ <>
1528
+ <Icon name="chevrons-up-down" />
1529
+ Sort
1530
+ {view.sort.length ? (
1531
+ <Badge variant="soft" tone="primary" className="tabular-nums">
1532
+ {view.sort.length}
1533
+ </Badge>
1534
+ ) : null}
1535
+ </>
1536
+ }
1537
+ >
1538
+ <SortPanel columns={orderedColumns(localCols, view)} sort={view.sort} custom={custom} onChange={(sort) => setView({ sort })} />
1539
+ </Popover>
1540
+ <Popover
1541
+ variant={hiddenCount ? "secondary" : "quiet"}
1542
+ size={btnSize}
1543
+ width="auto"
1544
+ align="start"
1545
+ label="Fields"
1546
+ trigger={
1547
+ <>
1548
+ <Icon name={hiddenCount ? "eye-off" : "eye"} />
1549
+ {hiddenCount ? `${hiddenCount} hidden` : "Fields"}
1550
+ </>
1551
+ }
1552
+ >
1553
+ <FieldsPanel columns={orderedColumns(localCols, view)} hidden={view.hidden} custom={custom} onChange={(hidden) => setView({ hidden })} />
1554
+ </Popover>
1555
+ <div className="ml-auto flex items-center gap-1">
1556
+ {!readOnly ? (
1557
+ <>
1558
+ <IconButton icon="undo" label="Undo" disabled={!undoStack.current.length} onClick={undo} className="max-sm:hidden" />
1559
+ <IconButton icon="redo" label="Redo" disabled={!redoStack.current.length} onClick={redo} className="max-sm:hidden" />
1560
+ </>
1561
+ ) : null}
1562
+ {toolbarEnd}
1563
+ {canAdd ? (
1564
+ narrow ? (
1565
+ <IconButton icon="plus" variant="primary" label={`Add a ${noun}`} onClick={() => addRows([newRow()], { edit: true })} />
1566
+ ) : (
1567
+ <Button variant="primary" icon="plus" onClick={() => addRows([newRow()], { edit: true })}>
1568
+ Add {noun}
1569
+ </Button>
1570
+ )
1571
+ ) : null}
1572
+ </div>
1573
+ </div>
1574
+ ) : null}
1575
+
1576
+ <div className="relative min-w-0 overflow-hidden rounded-lg border border-border bg-background" style={{ height }}>
1577
+ <ContextMenu items={menuItems} label="Cell actions" className="h-full">
1578
+ <div
1579
+ ref={scroller}
1580
+ role="grid"
1581
+ aria-label={label}
1582
+ aria-rowcount={viewRows.length + 1}
1583
+ aria-colcount={cols.length + 1}
1584
+ aria-multiselectable="true"
1585
+ aria-activedescendant={activeId}
1586
+ aria-busy={loading || undefined}
1587
+ tabIndex={0}
1588
+ className={cx("h-full overflow-auto overscroll-contain outline-none", !A && "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring")}
1589
+ onKeyDown={onKeyDown}
1590
+ onPointerDown={onPointerDown}
1591
+ onClick={onClick}
1592
+ onDoubleClick={onDoubleClick}
1593
+ onCopy={(e) => {
1594
+ if (editingRef.current || e.target !== scroller.current || !range) return;
1595
+ e.preventDefault();
1596
+ e.clipboardData.setData("text/plain", copyText());
1597
+ }}
1598
+ onCut={(e) => {
1599
+ if (editingRef.current || e.target !== scroller.current || !range) return;
1600
+ e.preventDefault();
1601
+ e.clipboardData.setData("text/plain", copyText());
1602
+ if (!readOnly) commitChanges(clearChanges(range, viewRows, cols, ctx, custom));
1603
+ }}
1604
+ onPaste={(e) => {
1605
+ if (editingRef.current || e.target !== scroller.current) return;
1606
+ e.preventDefault();
1607
+ pasteText(e.clipboardData.getData("text/plain"));
1608
+ }}
1609
+ >
1610
+ <div className="relative select-none" style={{ width: totalW, height: emptyBody ? undefined : contentH, minHeight: "100%" }}>
1611
+ {header}
1612
+ {loading
1613
+ ? Array.from({ length: 8 }, (_, i) => (
1614
+ <div key={i} className="absolute left-0 flex" style={{ top: headH + i * rowH, height: rowH, width: totalW }}>
1615
+ <div className="sticky left-0 z-10 shrink-0 border-b border-r border-border bg-background" style={{ width: gutterW }} />
1616
+ {cols.map((col) => (
1617
+ <div key={col.key} className="flex h-full shrink-0 items-center border-b border-r border-border px-2.5" style={{ width: col.width }}>
1618
+ <Skeleton className={cx("h-3", i % 3 === 0 ? "w-3/5" : i % 3 === 1 ? "w-4/5" : "w-2/5")} />
1619
+ </div>
1620
+ ))}
1621
+ </div>
1622
+ ))
1623
+ : vRows.map(renderRow)}
1624
+ {addLine && !emptyBody ? (
1625
+ <div className="absolute left-0 flex" style={{ top: headH + bodyH, height: rowH, width: totalW }}>
1626
+ {/* raw: a strip the height of a row that lines up with the grid's gutter and frozen columns; a kit Button has its own height and radius. */}
1627
+ <button
1628
+ type="button"
1629
+ tabIndex={-1}
1630
+ onClick={() => addRows([newRow()], { edit: true })}
1631
+ className="sticky left-0 z-10 flex h-full shrink-0 items-center gap-2 border-b border-r border-border bg-background pl-2.5 text-sm text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground"
1632
+ style={{ width: gutterW + frozenW }}
1633
+ >
1634
+ <Icon name="plus" size={14} />
1635
+ Add {noun}
1636
+ </button>
1637
+ </div>
1638
+ ) : null}
1639
+ {emptyBody ? (
1640
+ <div className="sticky left-0 grid place-items-center px-4 py-10" style={{ width: boxW ? boxW - 2 : "100%" }}>
1641
+ {emptyBody}
1642
+ </div>
1643
+ ) : null}
1644
+ </div>
1645
+ </div>
1646
+ </ContextMenu>
1647
+ </div>
1648
+
1649
+ <div className="flex min-h-6 flex-wrap items-center justify-between gap-x-4 gap-y-1 px-1 text-xs text-muted-foreground" aria-live="polite">
1650
+ <div className="flex items-center gap-3">
1651
+ <span className="tabular-nums">
1652
+ {loading ? "Loading…" : filtered ? `${viewRows.length.toLocaleString()} of ${plural(local.length, noun)}` : plural(local.length, noun)}
1653
+ </span>
1654
+ {order.current.dirty && (view.sort.length || view.filters.length) ? (
1655
+ <Button variant="link" size="inherit" onClick={() => setResortTick((n) => n + 1)}>
1656
+ {view.sort.length ? "Sort again" : "Filter again"}
1657
+ </Button>
1658
+ ) : null}
1659
+ {problems.size ? (
1660
+ <Button variant="link" size="inherit" tone="muted" onClick={nextProblem}>
1661
+ <span aria-hidden="true" className="mr-1.5 inline-block size-1.5 rounded-full bg-destructive align-middle" />
1662
+ {problems.size === 1 ? "1 problem" : `${problems.size.toLocaleString()} problems`}
1663
+ </Button>
1664
+ ) : null}
1665
+ </div>
1666
+ <div className="flex items-center gap-3">
1667
+ {activeProblem ? <span className="text-destructive">{activeProblem}</span> : null}
1668
+ {rowsMode ? (
1669
+ <>
1670
+ <span className="tabular-nums">{plural(range.r1 - range.r0 + 1, noun)} selected</span>
1671
+ {onDeleteRows && !readOnly ? (
1672
+ <Button variant="link" size="inherit" tone="muted" onClick={() => deleteRows(viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id))}>
1673
+ Delete
1674
+ </Button>
1675
+ ) : null}
1676
+ </>
1677
+ ) : summary ? (
1678
+ <span className="tabular-nums">
1679
+ {summary.nums > 1 ? `Sum ${fmtSum(summary.sum)} · Average ${fmtSum(summary.avg)} · ` : ""}
1680
+ {summary.filled.toLocaleString()} of {summary.count.toLocaleString()} filled
1681
+ </span>
1682
+ ) : null}
1683
+ {!narrow && !coarse ? (
1684
+ <Popover trigger={<Icon name="keyboard" size={16} />} variant="quiet" size="sm" width="auto" align="end" side="top" label="Keyboard shortcuts">
1685
+ <ShortcutsPanel />
1686
+ </Popover>
1687
+ ) : null}
1688
+ </div>
1689
+ </div>
1690
+ </div>
1691
+ );
1692
+ }
1693
+
1694
+ function restoreRows(rs, removed) {
1695
+ const out = rs.slice();
1696
+ for (const { row, index } of [...removed].sort((a, b) => a.index - b.index)) {
1697
+ if (out.some((r) => r.id === row.id)) continue;
1698
+ out.splice(Math.min(index, out.length), 0, row);
1699
+ }
1700
+ return out;
1701
+ }
1702
+
1703
+ function RenameInput({ name, onDone }) {
1704
+ const [text, setText] = useState(name);
1705
+ const input = useRef(null);
1706
+ const done = useRef(false);
1707
+ useLayoutEffect(() => {
1708
+ input.current?.focus();
1709
+ input.current?.select();
1710
+ }, []);
1711
+ const finish = (v) => {
1712
+ if (done.current) return;
1713
+ done.current = true;
1714
+ onDone(v);
1715
+ };
1716
+ return (
1717
+ // raw: a header-sized rename box inside a 40px column header; the kit's Input is a 44px field.
1718
+ <input
1719
+ ref={input}
1720
+ value={text}
1721
+ aria-label="Field name"
1722
+ onChange={(e) => setText(e.target.value)}
1723
+ onKeyDown={(e) => {
1724
+ e.stopPropagation();
1725
+ if (e.key === "Enter") finish(text.trim());
1726
+ else if (e.key === "Escape") finish(null);
1727
+ }}
1728
+ onBlur={() => finish(text.trim())}
1729
+ className="h-7 min-w-0 flex-1 rounded-sm border border-input bg-background px-1.5 text-xs font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
1730
+ />
1731
+ );
1732
+ }