makersclaw 0.8.0 → 0.8.2

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 (401) hide show
  1. package/README.md +9 -3
  2. package/package.json +1 -1
  3. package/r/activity-log.json +52 -0
  4. package/r/agent-changes.json +51 -0
  5. package/r/agent-chat.json +53 -0
  6. package/r/agent-column.json +85 -0
  7. package/r/agent-dock.json +57 -0
  8. package/r/agent-kit.json +49 -0
  9. package/r/agent-marks.json +49 -0
  10. package/r/agent-pane.json +93 -0
  11. package/r/ai-action.json +56 -0
  12. package/r/annotate.json +3 -3
  13. package/r/approval-request.json +2 -2
  14. package/r/approval.json +54 -0
  15. package/r/ask-bar.json +50 -0
  16. package/r/audio-player.json +2 -2
  17. package/r/auth-form.json +2 -2
  18. package/r/automation-editor.json +66 -0
  19. package/r/automation-list.json +57 -0
  20. package/r/automation-model.json +118 -0
  21. package/r/automations-screen.json +71 -0
  22. package/r/board.json +2 -2
  23. package/r/calendar-view.json +3 -3
  24. package/r/cart.json +2 -2
  25. package/r/chart.json +2 -2
  26. package/r/chat-pane.json +84 -0
  27. package/r/chat.json +2 -2
  28. package/r/code-editor.json +2 -2
  29. package/r/comment-box.json +3 -3
  30. package/r/connect-card.json +51 -0
  31. package/r/contact-section.json +2 -2
  32. package/r/core.json +13 -7
  33. package/r/cost.json +51 -0
  34. package/r/csv-import.json +3 -3
  35. package/r/data-picker.json +60 -0
  36. package/r/data-table.json +2 -2
  37. package/r/diagram.json +2 -2
  38. package/r/doc-editor.json +2 -2
  39. package/r/doc-page.json +3 -3
  40. package/r/email-render.json +2 -2
  41. package/r/faq.json +2 -2
  42. package/r/file-upload.json +2 -2
  43. package/r/first-run.json +63 -0
  44. package/r/fix-note.json +57 -0
  45. package/r/flow-canvas.json +2 -2
  46. package/r/flow-editor.json +79 -0
  47. package/r/footer.json +2 -2
  48. package/r/gantt.json +3 -3
  49. package/r/job-panel.json +2 -2
  50. package/r/json-editor.json +3 -3
  51. package/r/library-shelf.json +56 -0
  52. package/r/map.json +2 -2
  53. package/r/margin-notes.json +67 -0
  54. package/r/menubar.json +2 -2
  55. package/r/mind-map.json +3 -3
  56. package/r/navigation-menu.json +2 -2
  57. package/r/needs-you.json +55 -0
  58. package/r/order-detail.json +2 -2
  59. package/r/org-chart.json +3 -3
  60. package/r/pace-bar.json +69 -0
  61. package/r/page-builder.json +2 -2
  62. package/r/payment-methods.json +2 -2
  63. package/r/pdf-document.json +2 -2
  64. package/r/pdf-viewer.json +2 -2
  65. package/r/product-detail.json +2 -2
  66. package/r/product-grid.json +3 -3
  67. package/r/record-form.json +2 -2
  68. package/r/record-grid.json +52 -0
  69. package/r/record-notes.json +4 -4
  70. package/r/registry.json +4086 -1512
  71. package/r/run-history.json +52 -0
  72. package/r/run-view.json +80 -0
  73. package/r/schedule-card.json +54 -0
  74. package/r/schedule-engine.json +79 -0
  75. package/r/schedule-gantt.json +83 -0
  76. package/r/schedule-ruler.json +64 -0
  77. package/r/search-dialog.json +2 -2
  78. package/r/settings-list.json +2 -2
  79. package/r/setup-checklist.json +2 -2
  80. package/r/share-dialog.json +2 -2
  81. package/r/sidebar.json +2 -2
  82. package/r/signature-pad.json +2 -2
  83. package/r/sketch-canvas.json +2 -2
  84. package/r/source-receipt.json +52 -0
  85. package/r/spreadsheet.json +3 -3
  86. package/r/stat-cards.json +3 -3
  87. package/r/step-picker.json +53 -0
  88. package/r/step-settings.json +72 -0
  89. package/r/suggestion.json +58 -0
  90. package/r/sum-bar.json +64 -0
  91. package/r/task-list.json +3 -3
  92. package/r/task-model.json +2 -2
  93. package/r/testimonials.json +2 -2
  94. package/r/tier-map.json +67 -0
  95. package/r/time-grid.json +73 -0
  96. package/r/time-strip.json +64 -0
  97. package/r/top-bar.json +66 -0
  98. package/r/transaction-list.json +2 -2
  99. package/r/trigger-picker.json +61 -0
  100. package/r/whiteboard.json +3 -3
  101. package/r/workflow-view.json +55 -0
  102. package/registry/blocks/activity-log/README.md +112 -0
  103. package/registry/blocks/activity-log/activity-log.jsx +351 -0
  104. package/registry/blocks/activity-log/block.json +33 -0
  105. package/registry/blocks/activity-log/demo.jsx +48 -0
  106. package/registry/blocks/activity-log/index.js +4 -0
  107. package/registry/blocks/agent-changes/README.md +94 -0
  108. package/registry/blocks/agent-changes/agent-changes.jsx +118 -0
  109. package/registry/blocks/agent-changes/block.json +28 -0
  110. package/registry/blocks/agent-changes/demo.jsx +39 -0
  111. package/registry/blocks/agent-changes/index.js +3 -0
  112. package/registry/blocks/agent-chat/README.md +145 -0
  113. package/registry/blocks/agent-chat/agent-chat.jsx +415 -0
  114. package/registry/blocks/agent-chat/block.json +30 -0
  115. package/registry/blocks/agent-chat/demo.jsx +165 -0
  116. package/registry/blocks/agent-chat/index.js +3 -0
  117. package/registry/blocks/agent-column/README.md +197 -0
  118. package/registry/blocks/agent-column/agent-column.jsx +492 -0
  119. package/registry/blocks/agent-column/block.json +67 -0
  120. package/registry/blocks/agent-column/column-picker.jsx +163 -0
  121. package/registry/blocks/agent-column/demo.jsx +50 -0
  122. package/registry/blocks/agent-column/index.js +7 -0
  123. package/registry/blocks/agent-column/model.js +164 -0
  124. package/registry/blocks/agent-column/receipt.jsx +483 -0
  125. package/registry/blocks/agent-column/spreadsheet-type.jsx +24 -0
  126. package/registry/blocks/agent-dock/README.md +210 -0
  127. package/registry/blocks/agent-dock/agent-dock.jsx +356 -0
  128. package/registry/blocks/agent-dock/block.json +47 -0
  129. package/registry/blocks/agent-dock/demo.jsx +153 -0
  130. package/registry/blocks/agent-dock/dock-model.js +118 -0
  131. package/registry/blocks/agent-dock/index.js +5 -0
  132. package/registry/blocks/agent-kit/README.md +73 -0
  133. package/registry/blocks/agent-kit/agent-kit.jsx +86 -0
  134. package/registry/blocks/agent-kit/block.json +10 -0
  135. package/registry/blocks/agent-kit/demo.jsx +35 -0
  136. package/registry/blocks/agent-kit/index.js +3 -0
  137. package/registry/blocks/agent-marks/README.md +80 -0
  138. package/registry/blocks/agent-marks/agent-marks.jsx +133 -0
  139. package/registry/blocks/agent-marks/block.json +27 -0
  140. package/registry/blocks/agent-marks/demo.jsx +49 -0
  141. package/registry/blocks/agent-marks/index.js +3 -0
  142. package/registry/blocks/agent-pane/README.md +234 -0
  143. package/registry/blocks/agent-pane/agent-pane.jsx +246 -0
  144. package/registry/blocks/agent-pane/block.json +41 -0
  145. package/registry/blocks/agent-pane/composer.jsx +6 -0
  146. package/registry/blocks/agent-pane/demo.jsx +233 -0
  147. package/registry/blocks/agent-pane/dock-context.js +10 -0
  148. package/registry/blocks/agent-pane/index.js +4 -0
  149. package/registry/blocks/agent-pane/prose.jsx +84 -0
  150. package/registry/blocks/agent-pane/questions.jsx +134 -0
  151. package/registry/blocks/agent-pane/transcript.jsx +268 -0
  152. package/registry/blocks/agent-pane/work.jsx +368 -0
  153. package/registry/blocks/ai-action/README.md +94 -0
  154. package/registry/blocks/ai-action/ai-action.jsx +188 -0
  155. package/registry/blocks/ai-action/block.json +34 -0
  156. package/registry/blocks/ai-action/demo.jsx +93 -0
  157. package/registry/blocks/ai-action/index.js +3 -0
  158. package/registry/blocks/approval/README.md +145 -0
  159. package/registry/blocks/approval/approval.jsx +163 -0
  160. package/registry/blocks/approval/block.json +31 -0
  161. package/registry/blocks/approval/demo.jsx +121 -0
  162. package/registry/blocks/approval/index.js +3 -0
  163. package/registry/blocks/ask-bar/README.md +105 -0
  164. package/registry/blocks/ask-bar/ask-bar.jsx +166 -0
  165. package/registry/blocks/ask-bar/block.json +26 -0
  166. package/registry/blocks/ask-bar/demo.jsx +78 -0
  167. package/registry/blocks/ask-bar/index.js +3 -0
  168. package/registry/blocks/automation-editor/README.md +291 -0
  169. package/registry/blocks/automation-editor/automation-editor.jsx +656 -0
  170. package/registry/blocks/automation-editor/block.json +39 -0
  171. package/registry/blocks/automation-editor/demo.jsx +284 -0
  172. package/registry/blocks/automation-editor/index.js +4 -0
  173. package/registry/blocks/automation-editor/run-inputs.jsx +82 -0
  174. package/registry/blocks/automation-list/README.md +129 -0
  175. package/registry/blocks/automation-list/automation-list.jsx +476 -0
  176. package/registry/blocks/automation-list/block.json +35 -0
  177. package/registry/blocks/automation-list/demo.jsx +67 -0
  178. package/registry/blocks/automation-list/index.js +3 -0
  179. package/registry/blocks/automation-model/README.md +109 -0
  180. package/registry/blocks/automation-model/block.json +135 -0
  181. package/registry/blocks/automation-model/catalog.js +256 -0
  182. package/registry/blocks/automation-model/check.js +161 -0
  183. package/registry/blocks/automation-model/demo.jsx +42 -0
  184. package/registry/blocks/automation-model/edit.js +262 -0
  185. package/registry/blocks/automation-model/fixes.js +287 -0
  186. package/registry/blocks/automation-model/glyph.jsx +13 -0
  187. package/registry/blocks/automation-model/graph.js +410 -0
  188. package/registry/blocks/automation-model/index.js +33 -0
  189. package/registry/blocks/automation-model/overlay.js +175 -0
  190. package/registry/blocks/automation-model/parts.jsx +94 -0
  191. package/registry/blocks/automation-model/schedule.js +53 -0
  192. package/registry/blocks/automation-model/templates.js +128 -0
  193. package/registry/blocks/automation-model/values.js +29 -0
  194. package/registry/blocks/automation-model/words.js +439 -0
  195. package/registry/blocks/automations-screen/README.md +207 -0
  196. package/registry/blocks/automations-screen/automations-screen.jsx +452 -0
  197. package/registry/blocks/automations-screen/block.json +47 -0
  198. package/registry/blocks/automations-screen/demo.jsx +364 -0
  199. package/registry/blocks/automations-screen/index.js +4 -0
  200. package/registry/blocks/automations-screen/screen-model.js +129 -0
  201. package/registry/blocks/chat-pane/README.md +143 -0
  202. package/registry/blocks/chat-pane/block.json +48 -0
  203. package/registry/blocks/chat-pane/chat-attach.js +166 -0
  204. package/registry/blocks/chat-pane/chat-composer.jsx +195 -0
  205. package/registry/blocks/chat-pane/chat-context.js +17 -0
  206. package/registry/blocks/chat-pane/chat-model.js +43 -0
  207. package/registry/blocks/chat-pane/chat-pane.jsx +398 -0
  208. package/registry/blocks/chat-pane/chats-list.jsx +52 -0
  209. package/registry/blocks/chat-pane/demo.jsx +103 -0
  210. package/registry/blocks/chat-pane/index.js +9 -0
  211. package/registry/blocks/connect-card/README.md +99 -0
  212. package/registry/blocks/connect-card/block.json +10 -0
  213. package/registry/blocks/connect-card/connect-card.jsx +103 -0
  214. package/registry/blocks/connect-card/demo.jsx +49 -0
  215. package/registry/blocks/connect-card/index.js +3 -0
  216. package/registry/blocks/cost/README.md +115 -0
  217. package/registry/blocks/cost/block.json +10 -0
  218. package/registry/blocks/cost/cost.jsx +176 -0
  219. package/registry/blocks/cost/demo.jsx +66 -0
  220. package/registry/blocks/cost/index.js +3 -0
  221. package/registry/blocks/data-picker/README.md +81 -0
  222. package/registry/blocks/data-picker/block.json +38 -0
  223. package/registry/blocks/data-picker/data-picker.jsx +210 -0
  224. package/registry/blocks/data-picker/demo.jsx +34 -0
  225. package/registry/blocks/data-picker/fields.js +89 -0
  226. package/registry/blocks/data-picker/index.js +4 -0
  227. package/registry/blocks/data-table/demo.jsx +1 -1
  228. package/registry/blocks/first-run/README.md +118 -0
  229. package/registry/blocks/first-run/block.json +11 -0
  230. package/registry/blocks/first-run/demo.jsx +88 -0
  231. package/registry/blocks/first-run/first-run-model.js +51 -0
  232. package/registry/blocks/first-run/first-run.jsx +156 -0
  233. package/registry/blocks/first-run/index.js +5 -0
  234. package/registry/blocks/first-run/probe-rules.js +26 -0
  235. package/registry/blocks/fix-note/README.md +79 -0
  236. package/registry/blocks/fix-note/block.json +35 -0
  237. package/registry/blocks/fix-note/demo.jsx +30 -0
  238. package/registry/blocks/fix-note/fix-note.jsx +235 -0
  239. package/registry/blocks/fix-note/index.js +3 -0
  240. package/registry/blocks/flow-editor/README.md +108 -0
  241. package/registry/blocks/flow-editor/block.json +44 -0
  242. package/registry/blocks/flow-editor/demo.jsx +115 -0
  243. package/registry/blocks/flow-editor/flow-canvas.jsx +568 -0
  244. package/registry/blocks/flow-editor/flow-editor.jsx +154 -0
  245. package/registry/blocks/flow-editor/index.js +6 -0
  246. package/registry/blocks/flow-editor/minimap.jsx +78 -0
  247. package/registry/blocks/flow-editor/outline.jsx +134 -0
  248. package/registry/blocks/library-shelf/README.md +117 -0
  249. package/registry/blocks/library-shelf/block.json +28 -0
  250. package/registry/blocks/library-shelf/demo.jsx +68 -0
  251. package/registry/blocks/library-shelf/index.js +3 -0
  252. package/registry/blocks/library-shelf/library-shelf.jsx +211 -0
  253. package/registry/blocks/library-shelf/probe-rules.js +15 -0
  254. package/registry/blocks/margin-notes/README.md +222 -0
  255. package/registry/blocks/margin-notes/block.json +10 -0
  256. package/registry/blocks/margin-notes/demo.jsx +144 -0
  257. package/registry/blocks/margin-notes/index.js +20 -0
  258. package/registry/blocks/margin-notes/margin-model.js +224 -0
  259. package/registry/blocks/margin-notes/margin-notes.jsx +465 -0
  260. package/registry/blocks/needs-you/README.md +177 -0
  261. package/registry/blocks/needs-you/block.json +34 -0
  262. package/registry/blocks/needs-you/demo.jsx +143 -0
  263. package/registry/blocks/needs-you/index.js +3 -0
  264. package/registry/blocks/needs-you/needs-you.jsx +441 -0
  265. package/registry/blocks/pace-bar/README.md +230 -0
  266. package/registry/blocks/pace-bar/block.json +10 -0
  267. package/registry/blocks/pace-bar/demo.jsx +255 -0
  268. package/registry/blocks/pace-bar/index.js +22 -0
  269. package/registry/blocks/pace-bar/pace-bar.jsx +461 -0
  270. package/registry/blocks/pace-bar/pace-model.js +288 -0
  271. package/registry/blocks/record-grid/README.md +100 -0
  272. package/registry/blocks/record-grid/block.json +28 -0
  273. package/registry/blocks/record-grid/demo.jsx +43 -0
  274. package/registry/blocks/record-grid/index.js +3 -0
  275. package/registry/blocks/record-grid/record-grid.jsx +251 -0
  276. package/registry/blocks/run-history/README.md +138 -0
  277. package/registry/blocks/run-history/block.json +10 -0
  278. package/registry/blocks/run-history/demo.jsx +74 -0
  279. package/registry/blocks/run-history/index.js +3 -0
  280. package/registry/blocks/run-history/run-history.jsx +218 -0
  281. package/registry/blocks/run-view/README.md +174 -0
  282. package/registry/blocks/run-view/block.json +50 -0
  283. package/registry/blocks/run-view/demo.jsx +286 -0
  284. package/registry/blocks/run-view/index.js +5 -0
  285. package/registry/blocks/run-view/run-controls.jsx +266 -0
  286. package/registry/blocks/run-view/run-view.jsx +236 -0
  287. package/registry/blocks/run-view/runs-list.jsx +99 -0
  288. package/registry/blocks/schedule-card/README.md +121 -0
  289. package/registry/blocks/schedule-card/block.json +10 -0
  290. package/registry/blocks/schedule-card/demo.jsx +82 -0
  291. package/registry/blocks/schedule-card/index.js +3 -0
  292. package/registry/blocks/schedule-card/schedule-card.jsx +136 -0
  293. package/registry/blocks/schedule-engine/README.md +137 -0
  294. package/registry/blocks/schedule-engine/block.json +10 -0
  295. package/registry/blocks/schedule-engine/demo.jsx +82 -0
  296. package/registry/blocks/schedule-engine/engine.js +769 -0
  297. package/registry/blocks/schedule-engine/index.js +9 -0
  298. package/registry/blocks/schedule-engine/ops.js +71 -0
  299. package/registry/blocks/schedule-engine/place.js +294 -0
  300. package/registry/blocks/schedule-engine/tree.js +79 -0
  301. package/registry/blocks/schedule-engine/waits.js +80 -0
  302. package/registry/blocks/schedule-gantt/README.md +159 -0
  303. package/registry/blocks/schedule-gantt/block.json +10 -0
  304. package/registry/blocks/schedule-gantt/demo.jsx +223 -0
  305. package/registry/blocks/schedule-gantt/gantt-model.js +219 -0
  306. package/registry/blocks/schedule-gantt/geometry.js +176 -0
  307. package/registry/blocks/schedule-gantt/index.js +26 -0
  308. package/registry/blocks/schedule-gantt/schedule-gantt.jsx +882 -0
  309. package/registry/blocks/schedule-gantt/sheet.jsx +372 -0
  310. package/registry/blocks/schedule-gantt/track.jsx +190 -0
  311. package/registry/blocks/schedule-ruler/README.md +148 -0
  312. package/registry/blocks/schedule-ruler/block.json +10 -0
  313. package/registry/blocks/schedule-ruler/demo.jsx +181 -0
  314. package/registry/blocks/schedule-ruler/index.js +21 -0
  315. package/registry/blocks/schedule-ruler/ruler-model.js +239 -0
  316. package/registry/blocks/schedule-ruler/schedule-ruler.jsx +548 -0
  317. package/registry/blocks/source-receipt/README.md +97 -0
  318. package/registry/blocks/source-receipt/block.json +30 -0
  319. package/registry/blocks/source-receipt/demo.jsx +71 -0
  320. package/registry/blocks/source-receipt/index.js +3 -0
  321. package/registry/blocks/source-receipt/source-receipt.jsx +107 -0
  322. package/registry/blocks/spreadsheet/spreadsheet.jsx +15 -1
  323. package/registry/blocks/step-picker/README.md +78 -0
  324. package/registry/blocks/step-picker/block.json +31 -0
  325. package/registry/blocks/step-picker/demo.jsx +23 -0
  326. package/registry/blocks/step-picker/index.js +3 -0
  327. package/registry/blocks/step-picker/step-picker.jsx +144 -0
  328. package/registry/blocks/step-settings/README.md +100 -0
  329. package/registry/blocks/step-settings/block.json +44 -0
  330. package/registry/blocks/step-settings/demo.jsx +93 -0
  331. package/registry/blocks/step-settings/fields.jsx +58 -0
  332. package/registry/blocks/step-settings/flow-settings.jsx +115 -0
  333. package/registry/blocks/step-settings/index.js +5 -0
  334. package/registry/blocks/step-settings/step-settings.jsx +290 -0
  335. package/registry/blocks/suggestion/README.md +171 -0
  336. package/registry/blocks/suggestion/block.json +40 -0
  337. package/registry/blocks/suggestion/demo.jsx +91 -0
  338. package/registry/blocks/suggestion/index.js +3 -0
  339. package/registry/blocks/suggestion/suggestion.jsx +436 -0
  340. package/registry/blocks/sum-bar/README.md +122 -0
  341. package/registry/blocks/sum-bar/block.json +10 -0
  342. package/registry/blocks/sum-bar/demo.jsx +110 -0
  343. package/registry/blocks/sum-bar/index.js +5 -0
  344. package/registry/blocks/sum-bar/sum-bar.jsx +209 -0
  345. package/registry/blocks/sum-bar/sum-model.js +126 -0
  346. package/registry/blocks/tier-map/README.md +217 -0
  347. package/registry/blocks/tier-map/block.json +10 -0
  348. package/registry/blocks/tier-map/demo.jsx +222 -0
  349. package/registry/blocks/tier-map/index.js +24 -0
  350. package/registry/blocks/tier-map/tier-map.jsx +574 -0
  351. package/registry/blocks/tier-map/tier-model.js +318 -0
  352. package/registry/blocks/time-grid/README.md +305 -0
  353. package/registry/blocks/time-grid/block.json +10 -0
  354. package/registry/blocks/time-grid/demo.jsx +279 -0
  355. package/registry/blocks/time-grid/grid-model.js +322 -0
  356. package/registry/blocks/time-grid/index.js +25 -0
  357. package/registry/blocks/time-grid/time-grid.jsx +1325 -0
  358. package/registry/blocks/time-strip/README.md +141 -0
  359. package/registry/blocks/time-strip/block.json +10 -0
  360. package/registry/blocks/time-strip/demo.jsx +129 -0
  361. package/registry/blocks/time-strip/index.js +19 -0
  362. package/registry/blocks/time-strip/strip-model.js +178 -0
  363. package/registry/blocks/time-strip/time-strip.jsx +260 -0
  364. package/registry/blocks/top-bar/README.md +149 -0
  365. package/registry/blocks/top-bar/block.json +35 -0
  366. package/registry/blocks/top-bar/demo.jsx +78 -0
  367. package/registry/blocks/top-bar/index.js +5 -0
  368. package/registry/blocks/top-bar/probe-rules.js +74 -0
  369. package/registry/blocks/top-bar/top-bar-model.js +152 -0
  370. package/registry/blocks/top-bar/top-bar.jsx +244 -0
  371. package/registry/blocks/trigger-picker/README.md +98 -0
  372. package/registry/blocks/trigger-picker/block.json +38 -0
  373. package/registry/blocks/trigger-picker/demo.jsx +59 -0
  374. package/registry/blocks/trigger-picker/index.js +3 -0
  375. package/registry/blocks/trigger-picker/trigger-picker.jsx +374 -0
  376. package/registry/blocks/workflow-view/README.md +123 -0
  377. package/registry/blocks/workflow-view/block.json +10 -0
  378. package/registry/blocks/workflow-view/demo.jsx +90 -0
  379. package/registry/blocks/workflow-view/index.js +3 -0
  380. package/registry/blocks/workflow-view/workflow-view.jsx +203 -0
  381. package/src/install.mjs +77 -16
  382. package/src/registry.mjs +3 -0
  383. package/templates/next/browser/lib/data.js +6 -1
  384. package/templates/next/files/README.md +627 -24
  385. package/templates/next/files/check/automation-format.ts +2103 -0
  386. package/templates/next/files/check/automation-package.ts +142 -0
  387. package/templates/next/files/check/run.mjs +968 -9
  388. package/templates/next/files/components/ui/field.jsx +17 -4
  389. package/templates/next/files/components/ui/icon.jsx +13 -0
  390. package/templates/next/files/components/ui/index.js +2 -1
  391. package/templates/next/files/components/ui/layout.jsx +77 -2
  392. package/templates/next/files/components/ui/notice.jsx +70 -0
  393. package/templates/next/files/components/ui/page.jsx +124 -0
  394. package/templates/next/files/components/ui/stat.jsx +22 -7
  395. package/templates/next/files/components/ui/toast.jsx +6 -3
  396. package/templates/next/files/lib/automations.js +660 -0
  397. package/templates/next/files/lib/claw.js +241 -5
  398. package/templates/next/files/lib/door.js +92 -0
  399. package/templates/next/files/lib/hooks.js +1677 -8
  400. package/templates/next/files/lib/platform.js +529 -21
  401. package/templates/next/template.json +13 -3
package/r/sidebar.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Sidebar",
6
6
  "description": "An app's navigation down the left edge: app name or workspace switcher, groups of items with icons, counts and nested items, the signed-in person with their menu; collapses to icons (remembered) and becomes a Sheet behind a trigger on a phone. Optional: inset or floating look, a ⌘K search over every item, folding groups with colour dots, a second column (a mail list, or a parent's children to drill into), a \"New\" button and a \"…\" menu per item (a chat history).",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -62,7 +62,7 @@
62
62
  "docs": "Installed to components/makersclaw/blocks/sidebar. Import: import { Sidebar, SidebarLayout, SidebarTrigger, useSidebar } from \"@/components/makersclaw/blocks/sidebar\". Props and an example: components/makersclaw/blocks/sidebar/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
63
63
  "meta": {
64
64
  "makersclaw": {
65
- "version": "0.8.0",
65
+ "version": "0.8.2",
66
66
  "useWhen": "An app with more than a handful of places to go (inbox, deals, contacts, reports, settings, projects) that wants a real app shell: compose it in app/shell.jsx around the surfaces. Not for in-page tabs (Tabs) or a short strip of links."
67
67
  }
68
68
  }
@@ -5,7 +5,7 @@
5
5
  "title": "SignaturePad",
6
6
  "description": "A signature field (signature_pad) in the workspace theme: a full-name field, then draw on a paper box (smooth pen strokes, undo the last stroke, clear with Undo) or type the name set as a signature. Sign returns a record: a cropped transparent PNG in dark ink, the typed name, the mode and the time; the signed view shows it with Sign again. Works as a form field too, through a handle (toPNG, getSignature).",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "signature_pad@5.1.4"
@@ -65,7 +65,7 @@
65
65
  "docs": "Installed to components/makersclaw/blocks/signature-pad. Import: import { SignaturePad, useSignaturePad, signatureRecord, formatSignedAt } from \"@/components/makersclaw/blocks/signature-pad\". Props and an example: components/makersclaw/blocks/signature-pad/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
66
66
  "meta": {
67
67
  "makersclaw": {
68
- "version": "0.8.0",
68
+ "version": "0.8.2",
69
69
  "useWhen": "A screen needs someone to sign: accepting a proposal or a quote, a delivery or work-done sign-off, a waiver, a consent form, an approval on a record. The proposal-accept block is built on it. It records an acceptance with a picture of the signature — it is not a certified legal e-signature (no identity check, no audit trail beyond what you store)."
70
70
  }
71
71
  }
@@ -5,7 +5,7 @@
5
5
  "title": "SketchCanvas",
6
6
  "description": "A freeform drawing canvas (Excalidraw) in the workspace theme: a Miro-style tool rail (select, pan, draw, shapes, arrows, text, image, eraser, frames), a floating toolbar over the selection with colours from the theme's tokens, zoom and undo controls, a friendly empty state, and stamp tools that place ready-made drawings (UI wireframe pieces built in). Optional locked background image for annotating a screenshot. The sketch is plain JSON; helpers read its words for an agent, and the handle exports a PNG.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "@excalidraw/excalidraw@0.18.1"
@@ -92,7 +92,7 @@
92
92
  "docs": "Installed to components/makersclaw/blocks/sketch-canvas. Import: import { SketchCanvas, useSketchCanvas, SKETCH_TOOLS, WIREFRAME_STAMPS, STROKES, FILLS, emptySketch, isSketchEmpty, sketchToText, sketchItems, toSketch } from \"@/components/makersclaw/blocks/sketch-canvas\". Props and an example: components/makersclaw/blocks/sketch-canvas/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
93
93
  "meta": {
94
94
  "makersclaw": {
95
- "version": "0.8.0",
95
+ "version": "0.8.2",
96
96
  "useWhen": "A screen needs freehand drawing: a whiteboard, marking up a screenshot, sketching a UI wireframe, a diagram drawn by hand. The whiteboard, annotate and sketch-to-app blocks are built on it. Not for structured boards of cards and connectors (flow-canvas), charts (chart) or a kanban (board)."
97
97
  }
98
98
  }
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "source-receipt",
4
+ "type": "registry:block",
5
+ "title": "SourceReceipt",
6
+ "description": "Where one value came from, as a clipping: the page or the mail and its title, the address, the date it was read, a trust word (\"their own site\", \"a 2023 directory\") and the trust mark's shape, the words on it with the deciding ones underlined wherever they appear (case-insensitive, never HTML), and Open the source. A thin wrapper over agent-column's clipping (Data Enrichment's receipt) for a value that stands on its own, so every app draws a source the same way.",
7
+ "registryDependencies": [
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.2/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.2/r/agent-column.json"
10
+ ],
11
+ "dependencies": [],
12
+ "files": [
13
+ {
14
+ "path": "registry/blocks/source-receipt/index.js",
15
+ "type": "registry:component",
16
+ "target": "@components/makersclaw/blocks/source-receipt/index.js",
17
+ "content": "// The SourceReceipt block — `makersclaw add source-receipt`. Fixed:\n// compose it through its props (README.md beside this file).\nexport { SourceReceipt, markExcerpt } from \"./source-receipt\";\n"
18
+ },
19
+ {
20
+ "path": "registry/blocks/source-receipt/source-receipt.jsx",
21
+ "type": "registry:component",
22
+ "target": "@components/makersclaw/blocks/source-receipt/source-receipt.jsx",
23
+ "content": "\"use client\";\n\nimport { Button, Icon, cx } from \"../../ui\";\nimport { Clipping } from \"../agent-column\";\n\n// SourceReceipt — where one value came from, as a clipping (the app kit,\n// D15): the page or the mail, its date, a trust word (\"their own site\",\n// \"a 2023 directory\"), the words on it with the deciding ones underlined,\n// and Open the source. For a value that is not in an agent column: a\n// CRM's found title, a note's proof margin, a newsletter's source line.\n//\n// It is NOT a second receipt. The clipping is agent-column's (Data\n// Enrichment's \"the receipt\"); this wraps it for one source standing on\n// its own, so both draw the same thing. No door: the source is app data\n// the agent wrote. Props in, callbacks out.\n\n/** Wrap every occurrence of each mark in [[…]] (the clipping's mark for\n * deciding words), case-insensitive, longest first so no mark lands\n * inside another. With no marks, the excerpt's own [[…]] (the agent's)\n * stand; with marks, they replace them. Plain strings only: never HTML. */\nexport function markExcerpt(excerpt, marks) {\n const list = [...new Set((marks ?? []).map((m) => String(m ?? \"\").trim()).filter((m) => m && !/[[\\]]/.test(m)))].sort((a, b) => b.length - a.length);\n if (!list.length) return String(excerpt ?? \"\");\n const text = String(excerpt ?? \"\").replace(/\\[\\[|\\]\\]/g, \"\");\n if (!text) return text;\n const re = new RegExp(list.map((m) => m.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")).join(\"|\"), \"gi\");\n return text.replace(re, (hit) => `[[${hit}]]`);\n}\n\n/** \"acme.com\", from a URL; null when it is not one. */\nfunction siteOf(url) {\n try {\n return new URL(/^https?:/i.test(url) ? url : `https://${url}`).hostname.replace(/^www\\./i, \"\");\n } catch {\n return null;\n }\n}\n\n/**\n * title the page's or the mail's title (\"Careers · Klarwerk\")\n * site where: a site (\"klarwerk.de\") or a mailbox (\"Sofia Reyes\n * <sofia@crumb.co>\"); from `url` when left out\n * url the source's address; the clipping links it\n * kind \"site\" (default) or \"mail\": the icon before the trust word\n * date when it was read or sent, in words (\"read 22 Sep\")\n * trust a word for how far to trust it (\"their own site\")\n * sure sure · likely · guess: the trust mark's shape, beside the title\n * excerpt the words on it\n * marks the deciding words, underlined wherever they appear\n * onOpen() opens the source; without it, with a url, the source opens\n * in a new tab\n * openLabel default \"Open the source\"\n * action a node at the head's end (a \"Pick\" for one of two)\n * picked drawn as the one chosen\n */\nexport function SourceReceipt({\n title,\n site,\n url,\n kind = \"site\",\n date,\n trust,\n sure,\n excerpt,\n marks = [],\n onOpen,\n openLabel = \"Open the source\",\n action,\n picked = false,\n className,\n}) {\n const where = site ?? (url ? siteOf(url) : null);\n // The clipping draws a url as its linked address; a mailbox or a site\n // without one rides in its date line instead.\n const read = [url ? null : where, date].filter(Boolean).join(\" · \") || undefined;\n const href = url ? (/^https?:/i.test(url) ? url : `https://${url}`) : null;\n return (\n <Clipping\n source={{ url: url || undefined, title: title || where || undefined, evidence: excerpt ? markExcerpt(excerpt, marks) : undefined, read }}\n trust={sure}\n title={title || where || \"A source\"}\n action={action}\n picked={picked}\n className={cx(\"min-w-0 text-sm\", className)}\n >\n {trust || onOpen || href ? (\n <div className=\"flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs\">\n {trust ? (\n <span className=\"inline-flex min-w-0 items-center gap-1 text-muted-foreground\">\n <Icon name={kind === \"mail\" ? \"mail\" : \"globe\"} size={12} className=\"shrink-0\" />\n <span className=\"min-w-0 break-words\">{trust}</span>\n </span>\n ) : null}\n {onOpen ? (\n <Button variant=\"link\" size=\"inherit\" onClick={onOpen} className=\"ml-auto\">\n {openLabel} <Icon name=\"external\" size={12} />\n </Button>\n ) : href ? (\n <Button variant=\"link\" size=\"inherit\" href={href} target=\"_blank\" rel=\"noreferrer\" className=\"ml-auto\">\n {openLabel} <Icon name=\"external\" size={12} />\n </Button>\n ) : null}\n </div>\n ) : null}\n </Clipping>\n );\n}\n"
24
+ },
25
+ {
26
+ "path": "registry/blocks/source-receipt/README.md",
27
+ "type": "registry:file",
28
+ "target": "@components/makersclaw/blocks/source-receipt/README.md",
29
+ "content": "# SourceReceipt\n\nWhere one value came from, drawn as a clipping:\n\n- the page's or the mail's title, with the trust mark's shape beside it;\n- its address (linked) or the mailbox, and the date it was read;\n- the words on it, with the **deciding words underlined** wherever they\n appear;\n- a trust word (\"their own site\", \"a 2023 directory\");\n- **Open the source**.\n\nA member reads the claim and its proof in one look, and decides how far\nto trust it.\n\nIt is not a second receipt. The clipping is `agent-column`'s (Data\nEnrichment's \"the receipt\"); this block wraps it for one source standing\non its own: a CRM's found title, a note's proof margin, a newsletter's\nsource line. Inside a column of answers, use `agent-column`'s `Receipt`.\n\n```jsx\nimport { SourceReceipt } from \"../components/blocks/source-receipt\"; // a Makersclaw app (\"./blocks/source-receipt\" from components/)\nimport { SourceReceipt } from \"@/components/makersclaw/blocks/source-receipt\"; // a shadcn project\n```\n\nA client component. It requires `agent-column` (and through it\n`agent-marks`), laid with it.\n\n## Which hook feeds it\n\nNone. The source is app data the agent wrote beside the value: its\naddress, its title, the words it read, the words that decided. Keep\nthem in the record (a `source` column, a `sources` table) and pass them\nin.\n\n## Props\n\n- `title` — the page's or the mail's title (\"Careers · Klarwerk\").\n- `url` — the source's address. The clipping shows it short\n (\"klarwerk.de/careers\") and links it.\n- `site` — where, when there is no `url`: a mailbox (\"Sofia Reyes\n <sofia@crumb.co>\") or a site's name. From `url` when left out.\n- `kind` — `site` (default) or `mail`: the icon before the trust word.\n- `date` — when it was read or sent, in words (\"read 22 Sep\").\n- `trust` — a word for how far to trust it (\"their own site\", \"her\n signature\", \"a 2023 directory\").\n- `sure` — `sure` · `likely` · `guess`: the trust mark's shape beside\n the title (filled, half, dashed).\n- `excerpt` — the words on it.\n- `marks` — the deciding words, `string[]`. Every occurrence of each is\n underlined, whatever its case. Without `marks`, an excerpt's own\n `[[…]]` (the agent's marking) are underlined instead.\n- `onOpen()` — opens the source your way. Without it, a `url` opens in a\n new tab.\n- `openLabel` — default \"Open the source\".\n- `action` — a node at the head's end: a \"Pick 70\" when two sources\n disagree.\n- `picked` — drawn as the one chosen.\n- `className` — layout around it. Never colour.\n\n`markExcerpt(excerpt, marks)` is exported: the excerpt with `[[…]]`\naround every deciding word, as the clipping reads it.\n\n## Who may do what\n\nAnyone who can see the value can see its source. The receipt has no\nwrites; a Pick is your own `action`, and your own action's rules hold.\n\n## Never\n\n- Never build the excerpt from HTML or inject it: it is drawn as text,\n and the underlines are elements, never markup in a string.\n- Never show a value as found with no receipt to open.\n- Never colour the trust alone: the mark's shape and the word say it.\n\n## Example\n\nThe source of a contact's job title, under the field.\n\n```jsx\nimport { SourceReceipt } from \"./blocks/source-receipt\";\n\nexport function TitleSource({ person }) {\n const s = person.title_source;\n if (!s) return null;\n return (\n <SourceReceipt\n title={s.title}\n url={s.url}\n date={`read ${s.read_on}`}\n trust={s.trust}\n sure={s.sure}\n excerpt={s.words}\n marks={[person.job_title]}\n />\n );\n}\n```\n"
30
+ }
31
+ ],
32
+ "categories": [
33
+ "source",
34
+ "citation",
35
+ "receipt",
36
+ "provenance",
37
+ "evidence",
38
+ "quote",
39
+ "clipping",
40
+ "trust",
41
+ "proof",
42
+ "reference",
43
+ "found"
44
+ ],
45
+ "docs": "Installed to components/makersclaw/blocks/source-receipt. Import: import { SourceReceipt, markExcerpt } from \"@/components/makersclaw/blocks/source-receipt\". Props and an example: components/makersclaw/blocks/source-receipt/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
46
+ "meta": {
47
+ "makersclaw": {
48
+ "version": "0.8.2",
49
+ "useWhen": "A value the agent found should show its proof beside it: a contact's title from an email signature, a company's size from its site, a figure in a note or a newsletter with its source in the margin, two sources side by side for a person to pick. For a whole column of answers across records use agent-column; for a value proposed but not kept yet, put the receipt under a suggestion."
50
+ }
51
+ }
52
+ }
@@ -5,7 +5,7 @@
5
5
  "title": "Spreadsheet",
6
6
  "description": "A table you edit like Google Sheets or Airtable, virtualised for 10,000+ rows: typed columns (text, number, currency, date, single and multiple select, person, checkbox, link, rating) each with its own in-cell editor, a cell cursor and range selection with the mouse and arrow keys, typing to edit, copy and paste ranges as TSV (to and from Sheets and Excel), drag-to-fill and ⌘D fill down, undo/redo, column resize, drag to reorder, hide, freeze, rename, change type and add fields, row insert and delete with Undo, sort and filter panels, search, a sum/average of the selection, validation marks, and a phone layout. Edits go out as async callbacks answering { ok }, applied at once and rolled back on failure. A column-type registry and an imperative handle for blocks built on it.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "@tanstack/react-virtual@3.14.13"
@@ -21,7 +21,7 @@
21
21
  "path": "registry/blocks/spreadsheet/spreadsheet.jsx",
22
22
  "type": "registry:component",
23
23
  "target": "@components/makersclaw/blocks/spreadsheet/spreadsheet.jsx",
24
- "content": "\"use client\";\n\n// The Spreadsheet: a table you edit like Sheets or Airtable. Rows and\n// columns come in as props; every edit goes out through an async callback\n// that answers { ok }, applied at once and rolled back if it says no.\n//\n// Virtualised both ways (@tanstack/react-virtual): only the cells in the\n// window are in the DOM, so 10,000 rows scroll like 10. Rows have one fixed\n// height, which keeps every position arithmetic — no measuring.\n//\n// Layout: one scroll box. Inside it, a content box the size of the whole\n// table; the header row is sticky to its top; each row is absolutely\n// placed at its offset. In a row (and in the header) the row-number\n// gutter and the frozen columns are in flow and sticky to the left; the\n// other cells are absolutely placed at their column's offset, so the\n// virtualiser may hand back any set of them.\nimport { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { defaultRangeExtractor, useVirtualizer } from \"@tanstack/react-virtual\";\nimport {\n Badge,\n Button,\n ContextMenu,\n Icon,\n IconButton,\n InputGroup,\n Menu,\n Popover,\n Skeleton,\n cx,\n toast,\n} from \"../../ui\";\nimport { CellValue, editorFor } from \"./cells\";\nimport { AddFieldPanel, FieldsPanel, FilterPanel, ShortcutsPanel, SortPanel, TYPE_ORDER, sortWords, uid } from \"./chrome\";\nimport {\n applyChanges,\n clamp,\n clearChanges,\n fillChanges,\n inRange,\n invertChanges,\n moveBy,\n normalizeView,\n orderedColumns,\n parseTSV,\n pasteChanges,\n processRows,\n rangeOf,\n rangeText,\n realChanges,\n summarize,\n toTSV,\n visibleColumns,\n} from \"./grid\";\nimport { convertValue, isBlank, parseCell, typeOf } from \"./types\";\n\nconst SIZES = {\n desk: { row: 36, head: 40, gutter: 64 },\n touch: { row: 44, head: 44, gutter: 52 },\n};\nconst ADD_COL = 48; // the \"+\" at the end of the header\nconst MIN_W = 64;\nconst MAX_W = 800;\nconst FROZEN_NARROW = 148; // a frozen column's widest on a phone\n\nconst plural = (n, noun) => `${n.toLocaleString()} ${n === 1 ? noun : `${noun}s`}`;\n\nasync function call(fn, ...args) {\n if (!fn) return { ok: true };\n try {\n const r = await fn(...args);\n return r ?? { ok: true };\n } catch (e) {\n return { ok: false, error: e?.message };\n }\n}\n\nconst isMac = () => typeof navigator !== \"undefined\" && /Mac|iP(hone|ad)/.test(navigator.platform || navigator.userAgent);\n\nexport function Spreadsheet({\n rows = [],\n columns = [],\n onChange,\n onAddRows,\n onDeleteRows,\n onColumnsChange,\n view: viewProp,\n defaultView,\n onViewChange,\n people,\n types,\n validate,\n readOnly = false,\n lockFields = false,\n onExpandRow,\n onSelectionChange,\n createRow,\n loading = false,\n label = \"Table\",\n noun = \"row\",\n height = 560,\n toolbar = true,\n toolbarStart,\n toolbarEnd,\n empty,\n locale,\n currency,\n today,\n className,\n ref,\n}) {\n const gid = useId().replace(/[^\\w-]/g, \"\");\n const custom = types;\n\n // ── data: the props, with optimistic edits laid on ───────────────────\n const [local, setLocal] = useState(rows);\n const [seenRows, setSeenRows] = useState(rows);\n if (rows !== seenRows) {\n setSeenRows(rows);\n setLocal(rows);\n }\n const [localCols, setLocalCols] = useState(columns);\n const [seenCols, setSeenCols] = useState(columns);\n if (columns !== seenCols) {\n setSeenCols(columns);\n setLocalCols(columns);\n }\n const localRef = useRef(local);\n localRef.current = local;\n const colsRef = useRef(localCols);\n colsRef.current = localCols;\n\n // ── the view ─────────────────────────────────────────────────────────\n const [ownView, setOwnView] = useState(() => normalizeView(defaultView));\n const view = useMemo(() => normalizeView(viewProp ?? ownView), [viewProp, ownView]);\n const viewRef = useRef(view);\n viewRef.current = view;\n const setView = useCallback(\n (patch) => {\n const next = { ...viewRef.current, ...(typeof patch === \"function\" ? patch(viewRef.current) : patch) };\n viewRef.current = next;\n if (viewProp === undefined) setOwnView(next);\n onViewChange?.(next);\n },\n [viewProp, onViewChange],\n );\n const [search, setSearch] = useState(\"\");\n\n // ── the box: phone or desk ───────────────────────────────────────────\n const rootRef = useRef(null);\n const scroller = useRef(null);\n const [boxW, setBoxW] = useState(null);\n const [coarse, setCoarse] = useState(false);\n useLayoutEffect(() => {\n const el = rootRef.current;\n if (!el) return;\n setBoxW(el.clientWidth);\n const ro = new ResizeObserver(() => setBoxW(el.clientWidth));\n ro.observe(el);\n const mq = window.matchMedia?.(\"(pointer: coarse)\");\n const onMq = () => setCoarse(Boolean(mq?.matches));\n onMq();\n mq?.addEventListener?.(\"change\", onMq);\n return () => {\n ro.disconnect();\n mq?.removeEventListener?.(\"change\", onMq);\n };\n }, []);\n const narrow = boxW != null && boxW < 640;\n const size = narrow || coarse ? SIZES.touch : SIZES.desk;\n const rowH = size.row;\n const headH = size.head;\n const gutterW = size.gutter;\n\n const ctx = useMemo(() => ({ people: people ?? [], locale, currency }), [people, locale, currency]);\n const canEditCols = Boolean(onColumnsChange) && !readOnly && !lockFields;\n const canAdd = Boolean(onAddRows) && !readOnly;\n\n // ── columns in view ──────────────────────────────────────────────────\n const [resizing, setResizing] = useState(null); // { key, width } while a handle is dragged\n const cols = useMemo(() => {\n const list = visibleColumns(localCols, view, custom, narrow);\n return list.map((c, i) => {\n let w = resizing?.key === c.key ? resizing.width : c.width;\n if (narrow && i < view.frozen) w = Math.min(w, FROZEN_NARROW);\n return w === c.width ? c : { ...c, width: w };\n });\n }, [localCols, view, custom, narrow, resizing]);\n const frozenN = Math.min(view.frozen, cols.length);\n const colLeft = useMemo(() => {\n const out = [];\n let x = gutterW;\n for (const c of cols) {\n out.push(x);\n x += c.width;\n }\n out.push(x);\n return out;\n }, [cols, gutterW]);\n const frozenW = colLeft[frozenN] - gutterW;\n const totalW = colLeft[cols.length] + (canEditCols ? ADD_COL : 0);\n const colIndex = useMemo(() => new Map(cols.map((c, i) => [c.key, i])), [cols]);\n const typeAt = (c) => typeOf(cols[c], custom);\n\n // ── rows in view: filtered and sorted, then HELD ──────────────────────\n // An edit does not re-sort or re-filter: the row stays under the cursor\n // (as in Airtable) until the view changes or \"Sort again\" is pressed.\n // Rows added here go where they were added; rows that arrive from the\n // outside re-run the view.\n const [resortTick, setResortTick] = useState(0);\n const order = useRef({ key: null, ids: [], dirty: false });\n const addedHere = useRef(new Set());\n const hints = useRef(new Map()); // new row id → { after, before }\n const viewRows = useMemo(() => {\n const byId = new Map(local.map((r) => [r.id, r]));\n const key = [view.sort, view.filters, view.hidden, search, localCols, custom, ctx, resortTick];\n const prev = order.current;\n let ids = null;\n if (prev.key && key.every((k, i) => k === prev.key[i])) {\n const known = new Set(prev.ids);\n const fresh = [];\n for (const r of local) if (!known.has(r.id)) fresh.push(r.id);\n if (fresh.every((id) => addedHere.current.has(id)) && prev.all?.size !== undefined) {\n ids = prev.ids.filter((id) => byId.has(id));\n for (const id of fresh) {\n const h = hints.current.get(id);\n const at = h ? ids.indexOf(h.ref) : -1;\n if (at < 0) ids.push(id);\n else ids.splice(h.before ? at : at + 1, 0, id);\n }\n }\n }\n if (!ids) {\n ids = processRows(local, localCols, view, { search, ctx, custom }).map((r) => r.id);\n order.current = { key, ids, dirty: false, all: new Set(ids) };\n } else order.current = { ...prev, key, ids };\n return ids.map((id) => byId.get(id));\n }, [local, localCols, view, search, custom, ctx, resortTick]);\n const rowIndex = useMemo(() => new Map(viewRows.map((r, i) => [r.id, i])), [viewRows]);\n const rowById = useMemo(() => new Map(local.map((r) => [r.id, r])), [local]);\n 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() !== \"\";\n\n // ── problems (validate + required) ────────────────────────────────────\n const problems = useMemo(() => {\n const out = new Map();\n const needs = localCols.filter((c) => c.required);\n if (!validate && !needs.length) return out;\n const check = validate ? localCols : needs;\n for (const r of local) {\n for (const c of check) {\n let msg = null;\n if (c.required && typeOf(c, custom).empty(r[c.key])) msg = `${c.name} is required`;\n else if (validate) msg = validate(r, c) || null;\n if (msg) out.set(`${r.id}\\u0000${c.key}`, msg);\n }\n }\n return out;\n }, [local, localCols, validate, custom]);\n\n // ── selection ────────────────────────────────────────────────────────\n // By row id and column key, so it follows a row through a sort.\n const [sel, setSel] = useState(null); // { a: {id, key}, f: {id, key}, rows?: true }\n const [editing, setEditing] = useState(null); // { id, key, initial }\n const editingRef = useRef(editing);\n editingRef.current = editing;\n 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;\n 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;\n const range = A ? rangeOf(A, F) : null;\n const single = range && range.r0 === range.r1 && range.c0 === range.c1;\n const rowsMode = Boolean(sel?.rows && range);\n const stateRef = useRef({});\n stateRef.current = { A, F, range, viewRows, cols, rowsMode };\n\n const at = (r, c) => ({ id: viewRows[r]?.id, key: cols[c]?.key });\n const select = useCallback(\n (a, f = a, opts = {}) => {\n const { viewRows: vr, cols: cs } = stateRef.current;\n if (!vr.length || !cs.length) return setSel(null);\n const ca = { r: clamp(a.r, 0, vr.length - 1), c: clamp(a.c, 0, cs.length - 1) };\n const cf = { r: clamp(f.r, 0, vr.length - 1), c: clamp(f.c, 0, cs.length - 1) };\n 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 });\n },\n [],\n );\n\n const focusGrid = useCallback(() => scroller.current?.focus({ preventScroll: true }), []);\n\n // Keep a cell in the window: under the sticky header, right of the\n // frozen columns.\n const ensureVisible = useCallback(\n (r, c) => {\n const el = scroller.current;\n if (!el) return;\n if (r != null && r >= 0) {\n const top = headH + r * rowH;\n if (top < el.scrollTop + headH) el.scrollTop = top - headH;\n else if (top + rowH > el.scrollTop + el.clientHeight) el.scrollTop = top + rowH - el.clientHeight;\n }\n if (c != null && c >= frozenN && c < cols.length) {\n const left = colLeft[c];\n const right = colLeft[c + 1];\n const fixed = gutterW + frozenW;\n if (left < el.scrollLeft + fixed) el.scrollLeft = left - fixed;\n else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = right - el.clientWidth;\n }\n },\n [headH, rowH, frozenN, cols.length, colLeft, gutterW, frozenW],\n );\n\n // A row added here is selected and shown once it renders.\n const pendingFocus = useRef(null);\n useEffect(() => {\n const p = pendingFocus.current;\n if (!p || !rowIndex.has(p.id)) return;\n pendingFocus.current = null;\n const r = rowIndex.get(p.id);\n select({ r, c: 0 });\n ensureVisible(r, 0);\n if (p.edit && cols[0]) setEditing({ id: p.id, key: cols[0].key, initial: null });\n else focusGrid();\n }, [rowIndex, select, ensureVisible, focusGrid, cols]);\n\n useEffect(() => {\n if (!onSelectionChange) return;\n if (!range) return onSelectionChange(null);\n onSelectionChange({\n active: { rowId: viewRows[A.r]?.id, key: cols[A.c]?.key },\n rowIds: viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id),\n keys: cols.slice(range.c0, range.c1 + 1).map((c) => c.key),\n });\n // The ids, not the objects: a value edit is not a selection change.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [sel, viewRows.length, cols.length]);\n\n // ── history ──────────────────────────────────────────────────────────\n const undoStack = useRef([]);\n const redoStack = useRef([]);\n const [, setHist] = useState(0);\n const bump = () => setHist((n) => n + 1);\n\n // ── saving ───────────────────────────────────────────────────────────\n const updateColumns = useCallback(\n async (next, reason) => {\n const prev = colsRef.current;\n colsRef.current = next;\n setLocalCols(next);\n const res = await call(onColumnsChange, next, { reason });\n if (!res.ok) {\n colsRef.current = prev;\n setLocalCols(prev);\n toast.error(\"Couldn’t change the fields\", { description: res.error ?? \"Nothing was changed. Try again.\" });\n }\n return res;\n },\n [onColumnsChange],\n );\n\n const commitChanges = useCallback(\n async (input, { creates, history = true } = {}) => {\n const changes = realChanges(input);\n if (creates?.size) {\n const next = colsRef.current.map((c) => (creates.has(c.key) ? { ...c, options: [...(c.options ?? []), ...creates.get(c.key)] } : c));\n updateColumns(next, \"options\");\n }\n if (!changes.length) return { ok: true };\n setLocal((rs) => applyChanges(rs, changes));\n const v = viewRef.current;\n const watched = new Set([...v.sort.map((s) => s.key), ...v.filters.map((f) => f.key)]);\n if (changes.some((ch) => watched.has(ch.key))) order.current.dirty = true;\n const step = { kind: \"cells\", changes };\n if (history) {\n undoStack.current.push(step);\n if (undoStack.current.length > 100) undoStack.current.shift();\n redoStack.current = [];\n bump();\n }\n const res = await call(onChange, changes);\n if (!res.ok) {\n setLocal((rs) => applyChanges(rs, changes, \"prev\"));\n undoStack.current = undoStack.current.filter((s) => s !== step);\n bump();\n toast.error(changes.length === 1 ? \"Couldn’t save that change\" : `Couldn’t save ${changes.length} changes`, {\n description: res.error ?? \"It was put back as it was. Try again.\",\n });\n }\n return res;\n },\n [onChange, updateColumns],\n );\n\n const newRow = useCallback(\n (values = {}) => {\n const base = createRow ? createRow() : {};\n return { ...base, id: base.id ?? uid(\"row\"), ...values };\n },\n [createRow],\n );\n\n const addRows = useCallback(\n async (list, { index, hint, edit = false, history = true, restore = false } = {}) => {\n if (!list.length) return { ok: true };\n for (const r of list) addedHere.current.add(r.id);\n if (hint) list.forEach((r, i) => hints.current.set(r.id, i === 0 ? hint : { ref: list[i - 1].id }));\n setLocal((rs) => (index == null ? [...rs, ...list] : [...rs.slice(0, index), ...list, ...rs.slice(index)]));\n pendingFocus.current = restore ? null : { id: list[0].id, edit };\n const step = { kind: \"add\", rows: list, index };\n if (history) {\n undoStack.current.push(step);\n redoStack.current = [];\n bump();\n }\n const res = await call(onAddRows, list, { index, restore });\n if (!res.ok) {\n const gone = new Set(list.map((r) => r.id));\n setLocal((rs) => rs.filter((r) => !gone.has(r.id)));\n undoStack.current = undoStack.current.filter((s) => s !== step);\n bump();\n toast.error(list.length === 1 ? `Couldn’t add the ${noun}` : `Couldn’t add ${plural(list.length, noun)}`, { description: res.error ?? \"Try again.\" });\n } else if (Array.isArray(res.rows)) {\n // The server named them: swap the ids we made up for its own.\n const swap = new Map(list.map((r, i) => [r.id, res.rows[i]]).filter(([, x]) => x));\n if (swap.size) {\n for (const x of swap.values()) addedHere.current.add(x.id);\n setLocal((rs) => rs.map((r) => (swap.has(r.id) ? { ...r, ...swap.get(r.id) } : r)));\n order.current = { ...order.current, ids: order.current.ids.map((id) => (swap.has(id) ? swap.get(id).id ?? id : id)) };\n 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));\n }\n }\n return res;\n },\n [onAddRows, noun],\n );\n\n const deleteRows = useCallback(\n async (ids, { history = true, quiet = false } = {}) => {\n const gone = new Set(ids);\n const removed = [];\n localRef.current.forEach((r, i) => {\n if (gone.has(r.id)) removed.push({ row: r, index: i });\n });\n if (!removed.length) return { ok: true };\n setLocal((rs) => rs.filter((r) => !gone.has(r.id)));\n setSel(null);\n const step = { kind: \"delete\", removed };\n if (history) {\n undoStack.current.push(step);\n redoStack.current = [];\n bump();\n }\n const res = await call(onDeleteRows, ids);\n if (!res.ok) {\n setLocal((rs) => restoreRows(rs, removed));\n undoStack.current = undoStack.current.filter((s) => s !== step);\n bump();\n toast.error(`Couldn’t delete ${removed.length === 1 ? `the ${noun}` : plural(removed.length, noun)}`, { description: res.error ?? \"Nothing was deleted. Try again.\" });\n } else if (!quiet) {\n toast(removed.length === 1 ? `Deleted 1 ${noun}` : `Deleted ${plural(removed.length, noun)}`, {\n action: { label: \"Undo\", onClick: () => undoRef.current?.() },\n });\n }\n return res;\n },\n [onDeleteRows, noun],\n );\n\n async function undo() {\n const step = undoStack.current.pop();\n if (!step) return;\n bump();\n if (step.kind === \"cells\") await commitChanges(invertChanges(step.changes), { history: false });\n else if (step.kind === \"add\") await deleteRows(step.rows.map((r) => r.id), { history: false, quiet: true });\n else if (step.kind === \"delete\") {\n for (const { row } of step.removed) addedHere.current.add(row.id);\n setLocal((rs) => restoreRows(rs, step.removed));\n await call(onAddRows, step.removed.map((x) => x.row), { restore: true, index: step.removed[0].index });\n }\n redoStack.current.push(step);\n bump();\n focusGrid();\n }\n async function redo() {\n const step = redoStack.current.pop();\n if (!step) return;\n bump();\n if (step.kind === \"cells\") await commitChanges(step.changes, { history: false });\n else if (step.kind === \"add\") await addRows(step.rows, { index: step.index, history: false, restore: true });\n else if (step.kind === \"delete\") await deleteRows(step.removed.map((x) => x.row.id), { history: false, quiet: true });\n undoStack.current.push(step);\n bump();\n focusGrid();\n }\n const undoRef = useRef(undo);\n undoRef.current = undo;\n\n // ── editing ──────────────────────────────────────────────────────────\n const editable = (c) => !readOnly && cols[c] && !cols[c].readOnly && !cols[c].computed;\n\n function startEdit(r, c, initial = null) {\n if (!editable(c) || !viewRows[r]) return;\n const t = typeAt(c);\n const row = viewRows[r];\n if (t.editor === \"toggle\") return commitChanges([{ rowId: row.id, key: cols[c].key, value: !row[cols[c].key], prev: row[cols[c].key] }]);\n if (!editorFor(t)) return;\n select({ r, c });\n ensureVisible(r, c);\n setEditing({ id: row.id, key: cols[c].key, initial });\n }\n\n function moveActive(move) {\n const s = stateRef.current;\n if (!s.A) return;\n const d = { down: [1, 0], up: [-1, 0], right: [0, 1], left: [0, -1] }[move];\n if (!d) return;\n 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) };\n select(next);\n ensureVisible(next.r, next.c);\n }\n\n function commitEdit(value, move, extra) {\n const ed = editingRef.current;\n if (!ed) return;\n const row = rowById.get(ed.id);\n const creates = extra?.create?.length ? new Map([[ed.key, extra.create]]) : null;\n setEditing(null);\n focusGrid();\n commitChanges([{ rowId: ed.id, key: ed.key, value, prev: row?.[ed.key] }], { creates });\n if (move) moveActive(move);\n }\n function cancelEdit() {\n setEditing(null);\n focusGrid();\n }\n\n // ── clipboard ────────────────────────────────────────────────────────\n function copyText() {\n if (!range) return \"\";\n return toTSV(rangeText(range, viewRows, cols, ctx, custom));\n }\n\n function pasteText(text) {\n const matrix = parseTSV(text);\n if (!matrix.length || readOnly) return;\n // An empty table: the paste becomes its rows.\n const target = range ?? { r0: viewRows.length, r1: viewRows.length, c0: 0, c1: 0 };\n const { changes, errors, creates, extra } = pasteChanges(matrix, target, viewRows, cols, ctx, custom);\n commitChanges(changes, { creates });\n if (extra.length && canAdd) {\n const made = extra.map((line) => {\n const values = {};\n line.forEach((text, j) => {\n const col = cols[target.c0 + j];\n if (!col || col.readOnly) return;\n const res = parseCell(text, col, ctx, custom);\n if (!(\"error\" in res) && !res.create) values[col.key] = res.value;\n });\n return newRow(values);\n });\n addRows(made);\n }\n const h = matrix.length === 1 && matrix[0].length === 1 ? target.r1 - target.r0 : matrix.length - 1;\n const w = matrix.length === 1 && matrix[0].length === 1 ? target.c1 - target.c0 : Math.max(...matrix.map((l) => l.length)) - 1;\n 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) });\n if (errors.length) {\n toast.warning(errors.length === 1 ? \"1 cell was skipped\" : `${errors.length} cells were skipped`, {\n description: `${errors[0].error}${errors.length > 1 ? \", and more\" : \"\"}. The rest were pasted.`,\n });\n }\n }\n\n // ── keys ─────────────────────────────────────────────────────────────\n function onKeyDown(e) {\n if (editingRef.current) return;\n if (e.target !== scroller.current) return; // a header menu, an input\n const mod = e.metaKey || e.ctrlKey;\n const k = e.key;\n if (!viewRows.length || !cols.length) return;\n const s = A ?? null;\n\n if (mod && (k === \"z\" || k === \"Z\")) {\n e.preventDefault();\n return e.shiftKey ? redo() : undo();\n }\n if (mod && (k === \"y\" || k === \"Y\")) {\n e.preventDefault();\n return redo();\n }\n if (mod && (k === \"a\" || k === \"A\")) {\n e.preventDefault();\n return select({ r: 0, c: 0 }, { r: viewRows.length - 1, c: cols.length - 1 });\n }\n if (!s) {\n if (k.startsWith(\"Arrow\") || k === \"Tab\" || k === \"Enter\") {\n e.preventDefault();\n select({ r: 0, c: 0 });\n ensureVisible(0, 0);\n }\n return;\n }\n const page = Math.max(1, Math.floor(((scroller.current?.clientHeight ?? 400) - headH) / rowH) - 1);\n const moved = moveBy(k, e.shiftKey ? F : s, { rows: viewRows.length, cols: cols.length, page, jump: mod });\n if (moved) {\n e.preventDefault();\n if (e.shiftKey) select(s, moved, { rows: rowsMode && moved.c === F.c ? true : undefined });\n else select(moved);\n ensureVisible(moved.r, moved.c);\n return;\n }\n if (k === \"Tab\") {\n e.preventDefault();\n let { r, c } = s;\n if (e.shiftKey) {\n if (c > 0) c--;\n else if (r > 0) [r, c] = [r - 1, cols.length - 1];\n } else if (c < cols.length - 1) c++;\n else if (r < viewRows.length - 1) [r, c] = [r + 1, 0];\n select({ r, c });\n ensureVisible(r, c);\n return;\n }\n if (k === \"Escape\") {\n if (!single) {\n e.preventDefault();\n select(s);\n }\n return;\n }\n if (k === \"Enter\" || k === \"F2\") {\n e.preventDefault();\n if (k === \"Enter\" && mod && onExpandRow) return onExpandRow(viewRows[s.r]);\n return startEdit(s.r, s.c);\n }\n if (k === \"Delete\" || k === \"Backspace\") {\n e.preventDefault();\n if (readOnly) return;\n return commitChanges(clearChanges(range, viewRows, cols, ctx, custom));\n }\n if (k === \" \" && e.shiftKey) {\n e.preventDefault();\n return select({ r: range.r0, c: 0 }, { r: range.r1, c: cols.length - 1 }, { rows: true });\n }\n if (k === \" \" && mod) {\n e.preventDefault();\n return select({ r: 0, c: range.c0 }, { r: viewRows.length - 1, c: range.c1 });\n }\n if (mod && (k === \"d\" || k === \"D\")) {\n e.preventDefault();\n if (readOnly) return;\n // Several rows: the first row fills the rest. One row: the row above\n // fills it, as Sheets does.\n if (range.r1 > range.r0) return commitChanges(fillChanges({ ...range, r1: range.r0 }, range.r1, viewRows, cols));\n if (range.r0 > 0) return commitChanges(fillChanges({ ...range, r0: range.r0 - 1, r1: range.r0 - 1 }, range.r0, viewRows, cols));\n return;\n }\n if (k === \" \") {\n e.preventDefault();\n const t = typeAt(s.c);\n if (t.editor === \"toggle\" && !readOnly) {\n const first = viewRows[range.r0]?.[cols[s.c].key];\n const changes = [];\n for (let r = range.r0; r <= range.r1; r++)\n for (let c = range.c0; c <= range.c1; c++) {\n if (typeAt(c).editor !== \"toggle\" || !editable(c)) continue;\n changes.push({ rowId: viewRows[r].id, key: cols[c].key, value: !first, prev: viewRows[r][cols[c].key] });\n }\n return commitChanges(changes);\n }\n if (onExpandRow) return onExpandRow(viewRows[s.r]);\n return startEdit(s.r, s.c, \" \");\n }\n // A printable key starts an edit with that key as the text.\n if (k.length === 1 && !mod && !e.altKey) {\n const t = typeAt(s.c);\n if (t.editor === \"rating\" && /[0-9]/.test(k) && editable(s.c)) {\n e.preventDefault();\n const res = parseCell(k, cols[s.c], ctx, custom);\n 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] }]);\n return;\n }\n if (editorFor(t) && editable(s.c)) {\n e.preventDefault();\n startEdit(s.r, s.c, k);\n }\n }\n }\n\n // ── pointer ──────────────────────────────────────────────────────────\n const drag = useRef(null); // { mode: \"select\" | \"fill\", ... }\n const lastPointer = useRef(\"mouse\");\n const [fillTo, setFillToState] = useState(null);\n const fillRef = useRef(null);\n const setFillTo = (v) => {\n fillRef.current = v;\n setFillToState(v);\n };\n\n function cellFrom(target) {\n const el = target.closest?.(\"[data-cell]\");\n if (!el || !scroller.current?.contains(el)) return null;\n return { r: Number(el.dataset.r), c: Number(el.dataset.c) };\n }\n\n function rowAtY(clientY) {\n const el = scroller.current;\n const rect = el.getBoundingClientRect();\n return clamp(Math.floor((clientY - rect.top + el.scrollTop - headH) / rowH), 0, viewRows.length - 1);\n }\n\n function autoScroll(e) {\n const el = scroller.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (e.clientY > rect.bottom - 28) el.scrollTop += 18;\n else if (e.clientY < rect.top + headH + 12) el.scrollTop -= 18;\n if (e.clientX > rect.right - 28) el.scrollLeft += 18;\n else if (e.clientX < rect.left + gutterW + frozenW + 12 && el.scrollLeft > 0) el.scrollLeft -= 18;\n }\n\n useEffect(() => {\n function move(e) {\n const d = drag.current;\n if (!d) return;\n autoScroll(e);\n if (d.mode === \"fill\") setFillTo(rowAtY(e.clientY));\n else if (d.mode === \"select\") {\n const hit = cellFrom(document.elementFromPoint(e.clientX, e.clientY) ?? document.body);\n if (hit && (hit.r !== d.last?.r || hit.c !== d.last?.c)) {\n d.last = hit;\n select(d.anchor, hit);\n }\n } else if (d.mode === \"rows\") {\n const r = rowAtY(e.clientY);\n select({ r: d.anchor, c: 0 }, { r, c: stateRef.current.cols.length - 1 }, { rows: true });\n }\n }\n function up() {\n const d = drag.current;\n drag.current = null;\n if (d?.mode === \"fill\") {\n const to = fillRef.current;\n setFillTo(null);\n const { range: g, viewRows: vr, cols: cs } = stateRef.current;\n if (g && to != null && (to > g.r1 || to < g.r0)) {\n commitChanges(fillChanges(g, to, vr, cs));\n select({ r: Math.min(g.r0, to), c: g.c0 }, { r: Math.max(g.r1, to), c: g.c1 });\n }\n }\n }\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n return () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [select, commitChanges, headH, rowH, viewRows.length, gutterW, frozenW]);\n\n function onPointerDown(e) {\n lastPointer.current = e.pointerType || \"mouse\";\n const t = e.target;\n if (t.closest?.(\"[data-fill]\")) {\n e.preventDefault();\n e.stopPropagation();\n drag.current = { mode: \"fill\" };\n setFillTo(range?.r1 ?? null);\n return;\n }\n const gutter = t.closest?.(\"[data-gutter]\");\n if (gutter && !t.closest(\"[data-expand]\")) {\n const r = Number(gutter.dataset.r);\n if (e.button !== 0 && inRange(range ?? { r0: -1, r1: -1, c0: 0, c1: 0 }, r, 0)) return;\n e.preventDefault();\n focusGrid();\n const anchor = e.shiftKey && range ? range.r0 : r;\n select({ r: anchor, c: 0 }, { r, c: cols.length - 1 }, { rows: true });\n if (e.button === 0 && e.pointerType === \"mouse\") drag.current = { mode: \"rows\", anchor };\n return;\n }\n const hit = cellFrom(t);\n if (!hit) return;\n const ed = editingRef.current;\n if (ed && ed.id === viewRows[hit.r]?.id && ed.key === cols[hit.c]?.key) return;\n const secondary = e.button === 2 || (e.button === 0 && e.ctrlKey && isMac());\n if (secondary) {\n if (!range || !inRange(range, hit.r, hit.c)) select(hit);\n return;\n }\n if (e.button !== 0) return;\n if (t.closest?.(\"[data-toggle]\") && editable(hit.c)) {\n e.preventDefault();\n focusGrid();\n select(hit);\n const row = viewRows[hit.r];\n const key = cols[hit.c].key;\n commitChanges([{ rowId: row.id, key, value: !row[key], prev: row[key] }]);\n return;\n }\n if (t.closest?.(\"input, button, a, [role=radio], label\")) return; // a rating star, a link\n if (e.pointerType !== \"mouse\") return; // touch: a tap is handled on click, a drag scrolls\n e.preventDefault();\n focusGrid();\n if (e.shiftKey && A) select(A, hit);\n else select(hit);\n drag.current = { mode: \"select\", anchor: e.shiftKey && A ? A : hit, last: hit };\n }\n\n const isEditingAt = (hit) => {\n const ed = editingRef.current;\n return Boolean(ed && ed.id === viewRows[hit.r]?.id && ed.key === cols[hit.c]?.key);\n };\n\n function onClick(e) {\n if (lastPointer.current === \"mouse\") return;\n const hit = cellFrom(e.target);\n if (!hit || isEditingAt(hit) || e.target.closest?.(\"input, button, a, [role=radio], label, [data-toggle]\")) return;\n focusGrid();\n if (A && single && A.r === hit.r && A.c === hit.c) startEdit(hit.r, hit.c);\n else select(hit);\n }\n\n function onDoubleClick(e) {\n const hit = cellFrom(e.target);\n if (!hit || isEditingAt(hit) || e.target.closest?.(\"[data-toggle], a, [role=radio]\")) return;\n startEdit(hit.r, hit.c);\n }\n\n // ── column header actions ────────────────────────────────────────────\n const [renaming, setRenaming] = useState(null);\n const [colDrag, setColDrag] = useState(null); // { from, to }\n const [filterOpen, setFilterOpen] = useState(false);\n const [addOpen, setAddOpen] = useState(false);\n\n function reorder(fromKey, toIndex) {\n const all = orderedColumns(colsRef.current, viewRef.current).map((c) => c.key);\n const shown = cols.map((c) => c.key);\n const targetKey = shown[toIndex] ?? null; // insert before this one (null: at the end)\n const without = all.filter((k) => k !== fromKey);\n const at = targetKey ? without.indexOf(targetKey) : without.length;\n without.splice(at < 0 ? without.length : at, 0, fromKey);\n setView({ order: without });\n }\n\n function onHeadPointerDown(e, c) {\n if (e.button !== 0 || e.pointerType !== \"mouse\" || e.target.closest(\"button, input, [data-resize]\")) return;\n e.preventDefault();\n const x0 = e.clientX;\n let moved = false;\n const el = scroller.current;\n const target = (clientX) => {\n const rect = el.getBoundingClientRect();\n const x = clientX - rect.left + el.scrollLeft;\n let i = 0;\n while (i < cols.length && colLeft[i] + cols[i].width / 2 < x) i++;\n return i;\n };\n const move = (ev) => {\n if (!moved && Math.abs(ev.clientX - x0) < 5) return;\n moved = true;\n autoScroll(ev);\n setColDrag({ from: c, to: target(ev.clientX) });\n };\n const up = (ev) => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n if (ev.type === \"pointercancel\") return setColDrag(null);\n if (moved) {\n const to = target(ev.clientX);\n setColDrag(null);\n if (to !== c && to !== c + 1) reorder(cols[c].key, to);\n } else {\n focusGrid();\n if (!viewRows.length) return;\n if (ev.shiftKey && A) select({ r: 0, c: A.c }, { r: viewRows.length - 1, c });\n else select({ r: 0, c }, { r: viewRows.length - 1, c });\n }\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n }\n\n function onResizeDown(e, col) {\n e.preventDefault();\n e.stopPropagation();\n const x0 = e.clientX;\n const w0 = col.width;\n let last = w0;\n e.currentTarget.setPointerCapture?.(e.pointerId);\n const move = (ev) => {\n last = clamp(Math.round(w0 + ev.clientX - x0), MIN_W, MAX_W);\n setResizing({ key: col.key, width: last });\n };\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n setResizing(null);\n if (last !== w0) setView((v) => ({ widths: { ...v.widths, [col.key]: last } }));\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n }\n\n async function changeType(col, typeName) {\n if (col.type === typeName) return;\n const before = colsRef.current;\n let next = { ...col, type: typeName };\n const t = typeOf(next, custom);\n const changes = [];\n const creates = [];\n for (const r of localRef.current) {\n const v = r[col.key];\n if (typeOf(col, custom).empty(v) && t.name !== \"checkbox\") continue;\n if (t.editor === \"select\") {\n const res = parseCell(typeOf(col, custom).toText(v, col, ctx), { ...next, options: [...(next.options ?? []), ...creates] }, ctx, custom);\n if (\"error\" in res) continue;\n if (res.create) creates.push(...res.create);\n changes.push({ rowId: r.id, key: col.key, value: res.value, prev: v });\n } else changes.push({ rowId: r.id, key: col.key, value: convertValue(v, col, next, ctx, custom), prev: v });\n }\n if (creates.length) next = { ...next, options: [...(next.options ?? []), ...creates] };\n const res = await updateColumns(\n before.map((c) => (c.key === col.key ? next : c)),\n \"type\",\n );\n if (!res.ok) return;\n await commitChanges(changes, { history: false });\n toast(`${col.name} is now ${t.label.toLowerCase()}`, {\n action: {\n label: \"Undo\",\n onClick: () => {\n updateColumns(before, \"type\");\n commitChanges(invertChanges(realChanges(changes)), { history: false });\n },\n },\n });\n }\n\n function insertColumn(c, side) {\n const names = new Set(colsRef.current.map((x) => x.name));\n let n = colsRef.current.length + 1;\n while (names.has(`Field ${n}`)) n++;\n const col = { key: uid(\"field\"), name: `Field ${n}`, type: \"text\" };\n addColumn(col, side === \"left\" ? c : c + 1);\n setRenaming(col.key);\n }\n\n function addColumn(col, visibleIndex = cols.length) {\n const all = colsRef.current;\n updateColumns([...all, col], \"add\");\n const order = orderedColumns(all, viewRef.current).map((x) => x.key);\n const beforeKey = cols[visibleIndex]?.key;\n const i = beforeKey ? order.indexOf(beforeKey) : order.length;\n order.splice(i < 0 ? order.length : i, 0, col.key);\n setView({ order });\n }\n\n function deleteColumn(col) {\n const before = colsRef.current;\n updateColumns(\n before.filter((c) => c.key !== col.key),\n \"delete\",\n );\n toast(`Deleted ${col.name}`, { action: { label: \"Undo\", onClick: () => updateColumns(before, \"restore\") } });\n }\n\n function headerItems(col, c) {\n const t = typeOf(col, custom);\n const words = sortWords(t);\n const sorted = view.sort.find((s) => s.key === col.key);\n return [\n { label: `Sort ${words.asc}`, icon: \"arrow-up\", onSelect: () => setView({ sort: [{ key: col.key, dir: \"asc\" }] }) },\n { label: `Sort ${words.desc}`, icon: \"arrow-down\", onSelect: () => setView({ sort: [{ key: col.key, dir: \"desc\" }] }) },\n sorted && { label: \"Remove sort\", icon: \"x\", onSelect: () => setView((v) => ({ sort: v.sort.filter((s) => s.key !== col.key) })) },\n {\n label: \"Filter by this field\",\n icon: \"filter\",\n onSelect: () => {\n setView((v) => ({ filters: [...v.filters, { id: uid(\"f\"), key: col.key, op: t.allFilters[0].id, value: \"\" }] }));\n setFilterOpen(true);\n },\n },\n { type: \"separator\" },\n canEditCols && { label: \"Rename\", icon: \"pencil\", onSelect: () => setRenaming(col.key) },\n canEditCols &&\n !col.computed && {\n label: \"Field type\",\n icon: t.icon,\n items: [\n {\n type: \"radio\",\n value: col.type ?? \"text\",\n onValueChange: (v) => changeType(col, v),\n options: [...TYPE_ORDER, ...Object.keys(custom ?? {}).filter((k) => !TYPE_ORDER.includes(k))].map((k) => ({ value: k, label: typeOf({ type: k }, custom).label })),\n },\n ],\n },\n canEditCols && { label: \"Insert left\", icon: \"arrow-left\", onSelect: () => insertColumn(c, \"left\") },\n canEditCols && { label: \"Insert right\", icon: \"arrow-right\", onSelect: () => insertColumn(c, \"right\") },\n canEditCols && { type: \"separator\" },\n c < frozenN\n ? { label: c === 0 && frozenN === 1 ? \"Unfreeze\" : \"Freeze up to here\", icon: \"pin\", onSelect: () => setView({ frozen: c === 0 && frozenN === 1 ? 0 : c + 1 }) }\n : { label: \"Freeze up to here\", icon: \"pin\", onSelect: () => setView({ frozen: c + 1 }) },\n cols.length > 1 && { label: \"Hide\", icon: \"eye-off\", onSelect: () => setView((v) => ({ hidden: [...v.hidden, col.key] })) },\n canEditCols && cols.length > 1 && { label: \"Delete field\", icon: \"trash\", tone: \"danger\", onSelect: () => deleteColumn(col) },\n ];\n }\n\n // ── the context menu (right-click, long press, Shift+F10) ─────────────\n const menuItems = (() => {\n if (!range || !viewRows.length) return [];\n const ids = viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id);\n const n = ids.length;\n const row = viewRows[range.r0];\n const rowAt = local.findIndex((x) => x.id === row?.id);\n return [\n {\n label: \"Copy\",\n icon: \"copy\",\n shortcut: \"⌘C\",\n onSelect: () => navigator.clipboard?.writeText(copyText()).then(() => toast.success(\"Copied\"), () => toast.error(\"Couldn’t reach the clipboard — use ⌘C\")),\n },\n !readOnly && { label: \"Clear\", icon: \"eraser\", shortcut: \"⌫\", onSelect: () => commitChanges(clearChanges(range, viewRows, cols, ctx, custom)) },\n !readOnly && range.r1 > range.r0 && { label: \"Fill down\", icon: \"arrow-down\", shortcut: \"⌘D\", onSelect: () => commitChanges(fillChanges({ ...range, r1: range.r0 }, range.r1, viewRows, cols)) },\n onExpandRow && n === 1 && { label: `Open ${noun}`, icon: \"maximize\", shortcut: \"Space\", onSelect: () => onExpandRow(row) },\n canAdd && { type: \"separator\" },\n canAdd && { label: `Insert ${noun} above`, icon: \"arrow-up\", onSelect: () => addRows([newRow()], { index: rowAt, hint: { ref: row.id, before: true } }) },\n canAdd && { label: `Insert ${noun} below`, icon: \"arrow-down\", onSelect: () => addRows([newRow()], { index: rowAt + 1, hint: { ref: row.id } }) },\n onDeleteRows && !readOnly && { type: \"separator\" },\n onDeleteRows && !readOnly && { label: n === 1 ? `Delete ${noun}` : `Delete ${plural(n, noun)}`, icon: \"trash\", tone: \"danger\", onSelect: () => deleteRows(ids) },\n ];\n })();\n\n // ── virtualisers ─────────────────────────────────────────────────────\n const activeRow = useRef(-1);\n activeRow.current = A ? A.r : -1;\n const activeCol = useRef(-1);\n activeCol.current = A ? A.c - frozenN : -1;\n const keepRow = useCallback((range) => {\n const out = defaultRangeExtractor(range);\n const a = activeRow.current;\n if (a >= 0 && a < range.count && !out.includes(a)) {\n out.push(a);\n out.sort((x, y) => x - y);\n }\n return out;\n }, []);\n const keepCol = useCallback((range) => {\n const out = defaultRangeExtractor(range);\n const a = activeCol.current;\n if (a >= 0 && a < range.count && !out.includes(a)) {\n out.push(a);\n out.sort((x, y) => x - y);\n }\n return out;\n }, []);\n\n const shownRows = loading ? 0 : viewRows.length;\n const rowV = useVirtualizer({\n count: shownRows,\n getScrollElement: () => scroller.current,\n estimateSize: () => rowH,\n overscan: 10,\n scrollMargin: headH,\n initialRect: { width: 1200, height: typeof height === \"number\" ? height : 560 },\n rangeExtractor: keepRow,\n getItemKey: (i) => viewRows[i]?.id ?? i,\n });\n const scrollCols = cols.slice(frozenN);\n const colV = useVirtualizer({\n horizontal: true,\n count: scrollCols.length,\n getScrollElement: () => scroller.current,\n estimateSize: (i) => scrollCols[i]?.width ?? 160,\n overscan: 2,\n scrollMargin: gutterW + frozenW,\n initialRect: { width: 1200, height: typeof height === \"number\" ? height : 560 },\n rangeExtractor: keepCol,\n getItemKey: (i) => scrollCols[i]?.key ?? i,\n });\n const widthsSig = `${gutterW}|${frozenW}|${scrollCols.map((c) => c.width).join(\",\")}`;\n useLayoutEffect(() => {\n colV.measure();\n }, [widthsSig, colV]);\n useLayoutEffect(() => {\n rowV.measure();\n }, [rowH, headH, rowV]);\n\n const vRows = rowV.getVirtualItems();\n const vCols = colV.getVirtualItems();\n const bodyH = shownRows * rowH;\n const addLine = canAdd && !loading;\n const contentH = headH + (loading ? 8 * rowH : bodyH) + (addLine ? rowH : 0);\n\n // The imperative handle: for a parent that drives the grid (base-view).\n useImperativeHandle(\n ref,\n () => ({\n focus: focusGrid,\n select: ({ rowId, key, toRowId, toKey } = {}) => {\n const r = rowIndex.get(rowId);\n const c = key == null ? 0 : colIndex.get(key);\n if (r == null || c == null) return false;\n const r2 = toRowId != null ? rowIndex.get(toRowId) ?? r : r;\n const c2 = toKey != null ? colIndex.get(toKey) ?? c : c;\n select({ r, c }, { r: r2, c: c2 });\n ensureVisible(r, c);\n return true;\n },\n getSelection: () =>\n range\n ? {\n active: { rowId: viewRows[A.r]?.id, key: cols[A.c]?.key },\n rowIds: viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id),\n keys: cols.slice(range.c0, range.c1 + 1).map((c) => c.key),\n }\n : null,\n clearSelection: () => setSel(null),\n scrollToRow: (rowId) => {\n const r = rowIndex.get(rowId);\n if (r == null) return false;\n ensureVisible(r, null);\n return true;\n },\n edit: (rowId, key) => {\n const r = rowIndex.get(rowId);\n const c = colIndex.get(key);\n if (r == null || c == null) return false;\n startEdit(r, c);\n return true;\n },\n addRow: (values) => addRows([newRow(values)], { edit: true }),\n deleteRows: (ids) => deleteRows(ids),\n undo,\n redo,\n copy: copyText,\n paste: pasteText,\n visibleRows: () => viewRows,\n visibleColumns: () => cols,\n resort: () => setResortTick((n) => n + 1),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [rowIndex, colIndex, range, viewRows, cols, A],\n );\n\n // ── drawing ──────────────────────────────────────────────────────────\n 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;\n const activeId = A ? `${gid}-c-${A.r}-${A.c}` : undefined;\n\n function cellShadow(r, c) {\n if (!range || !inRange(range, r, c)) return undefined;\n const P = \"var(--color-primary)\";\n if (A && A.r === r && A.c === c) return `inset 0 0 0 2px ${P}`;\n const s = [];\n if (r === range.r0) s.push(`inset 0 1px 0 0 ${P}`);\n if (r === range.r1) s.push(`inset 0 -1px 0 0 ${P}`);\n if (c === range.c0) s.push(`inset 1px 0 0 0 ${P}`);\n if (c === range.c1) s.push(`inset -1px 0 0 0 ${P}`);\n return s.join(\", \") || undefined;\n }\n\n function renderCell(row, r, col, c, sticky) {\n const t = typeOf(col, custom);\n const isActive = A && A.r === r && A.c === c;\n const inSel = range && inRange(range, r, c);\n const isEditing = editing && editing.id === row.id && editing.key === col.key;\n const problem = problems.get(`${row.id}\\u0000${col.key}`);\n const Editor = isEditing ? editorFor(t) : null;\n const corner = range && !editing && !readOnly && r === range.r1 && c === range.c1 && lastPointer.current === \"mouse\";\n const filling = fillRange && inRange(fillRange, r, c) && !inRange(range, r, c);\n return (\n <div\n key={col.key}\n id={`${gid}-c-${r}-${c}`}\n role=\"gridcell\"\n aria-colindex={c + 2}\n aria-selected={inSel ? true : undefined}\n aria-readonly={!editable(c) || undefined}\n aria-invalid={problem ? true : undefined}\n title={problem || undefined}\n data-cell=\"\"\n data-r={r}\n data-c={c}\n className={cx(\n \"flex h-full shrink-0 items-center border-b border-r border-border bg-background px-2.5 text-sm text-foreground\",\n sticky ? \"sticky z-10\" : \"absolute top-0\",\n sticky && c === frozenN - 1 && \"border-r-input\",\n isActive && !sticky && \"z-[2]\",\n isEditing && \"z-20\",\n t.align === \"end\" && \"justify-end\",\n )}\n style={{ width: col.width, left: sticky ? colLeft[c] : colLeft[c] }}\n >\n <span\n aria-hidden=\"true\"\n className={cx(\n \"pointer-events-none absolute inset-0\",\n inSel ? (single ? \"\" : \"bg-primary/8\") : filling ? \"bg-primary/5\" : \"group-hover/row:bg-muted/60\",\n rowsMode && inSel && \"bg-primary/8\",\n )}\n style={{ boxShadow: cellShadow(r, c) }}\n />\n <span className={cx(\"relative flex h-full min-w-0 flex-1 items-center\", t.align === \"end\" && \"justify-end\")}>\n {Editor ? (\n <Editor\n value={row[col.key]}\n column={col}\n type={t}\n ctx={ctx}\n initial={editing.initial}\n today={today}\n canCreate={!readOnly}\n onCommit={commitEdit}\n onCancel={cancelEdit}\n />\n ) : (\n <CellValue\n value={row[col.key]}\n column={col}\n type={t}\n ctx={ctx}\n active={isActive}\n readOnly={!editable(c)}\n onSet={(v) => {\n commitChanges([{ rowId: row.id, key: col.key, value: v, prev: row[col.key] }]);\n focusGrid();\n }}\n />\n )}\n </span>\n {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}\n {corner ? (\n <span\n data-fill=\"\"\n aria-hidden=\"true\"\n title=\"Drag to fill\"\n className=\"absolute -bottom-[4px] -right-[4px] z-30 size-[9px] cursor-crosshair rounded-[2px] border-2 border-background bg-primary\"\n />\n ) : null}\n </div>\n );\n }\n\n function renderRow(vr) {\n const r = vr.index;\n const row = viewRows[r];\n if (!row) return null;\n const rowSelected = rowsMode && range && r >= range.r0 && r <= range.r1;\n const rowActive = A && A.r === r;\n return (\n <div\n key={row.id}\n role=\"row\"\n aria-rowindex={r + 2}\n aria-selected={rowSelected || undefined}\n className=\"group/row absolute left-0 top-0 flex\"\n style={{ height: rowH, width: totalW, transform: `translateY(${vr.start}px)` }}\n >\n <div\n role=\"rowheader\"\n data-gutter=\"\"\n data-r={r}\n className={cx(\n \"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\",\n rowSelected ? \"text-primary\" : rowActive ? \"text-foreground\" : \"text-muted-foreground\",\n )}\n style={{ width: gutterW }}\n >\n <span aria-hidden=\"true\" className={cx(\"pointer-events-none absolute inset-0\", rowSelected ? \"bg-primary/8\" : \"group-hover/row:bg-muted/60\")} />\n <span className=\"relative\">{r + 1}</span>\n {onExpandRow && !narrow ? (\n // raw: a 24px open-row glyph inside the 64px gutter, shown on the row's hover; IconButton's smallest square is 36px.\n <button\n type=\"button\"\n data-expand=\"\"\n tabIndex={-1}\n aria-label={`Open ${noun} ${r + 1}`}\n title={`Open ${noun}`}\n onClick={() => onExpandRow(row)}\n 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\"\n >\n <Icon name=\"maximize\" size={13} />\n </button>\n ) : null}\n </div>\n {cols.slice(0, frozenN).map((col, c) => renderCell(row, r, col, c, true))}\n {vCols.map((vc) => renderCell(row, r, scrollCols[vc.index], vc.index + frozenN, false))}\n </div>\n );\n }\n\n function renderHeadCell(col, c, sticky) {\n const t = typeOf(col, custom);\n const sortAt = view.sort.findIndex((s) => s.key === col.key);\n const sortDir = sortAt >= 0 ? view.sort[sortAt].dir : null;\n const isFiltered = view.filters.some((f) => f.key === col.key);\n const colSelected = range && range.r0 === 0 && range.r1 === viewRows.length - 1 && c >= range.c0 && c <= range.c1 && viewRows.length > 1;\n const dragging = colDrag && colDrag.from === c;\n return (\n <div\n key={col.key}\n role=\"columnheader\"\n aria-colindex={c + 2}\n aria-sort={sortDir === \"asc\" ? \"ascending\" : sortDir === \"desc\" ? \"descending\" : undefined}\n className={cx(\n \"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\",\n sticky ? \"sticky z-10\" : \"absolute top-0\",\n sticky && c === frozenN - 1 && \"border-r-input\",\n dragging && \"opacity-50\",\n )}\n style={{ width: col.width, left: colLeft[c] }}\n >\n {colSelected ? <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 bg-primary/10\" /> : null}\n <span className=\"relative flex min-w-0 flex-1 cursor-default items-center gap-1.5 self-stretch\" onPointerDown={(e) => onHeadPointerDown(e, c)}>\n <Icon name={t.icon} size={14} className=\"shrink-0 text-muted-foreground\" />\n {renaming === col.key ? (\n <RenameInput\n name={col.name}\n onDone={(name) => {\n setRenaming(null);\n focusGrid();\n if (name && name !== col.name) updateColumns(colsRef.current.map((x) => (x.key === col.key ? { ...x, name } : x)), \"rename\");\n }}\n />\n ) : (\n <span className=\"truncate\">{col.name}</span>\n )}\n {col.readOnly || col.computed ? <Icon name=\"lock\" size={12} className=\"shrink-0 text-muted-foreground\" label=\"Read only\" /> : null}\n {col.required ? (\n <span aria-label=\"Required\" className=\"text-destructive\">\n *\n </span>\n ) : null}\n </span>\n {sortDir ? (\n <span className=\"relative flex shrink-0 items-center text-primary\" title={`Sorted ${sortWords(t)[sortDir]}`}>\n <Icon name={sortDir === \"asc\" ? \"arrow-up\" : \"arrow-down\"} size={13} />\n {view.sort.length > 1 ? <span className=\"text-2xs tabular-nums\">{sortAt + 1}</span> : null}\n </span>\n ) : null}\n {isFiltered ? <span aria-label=\"Filtered\" title=\"Filtered\" className=\"relative size-1.5 shrink-0 rounded-full bg-primary\" /> : null}\n <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\")}>\n <Menu items={headerItems(col, c)} label={`${col.name}, field menu`} trigger={<Icon name=\"chevron-down\" size={14} />} align=\"start\" />\n </span>\n <span\n data-resize=\"\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={`Resize ${col.name}`}\n aria-valuenow={col.width}\n aria-valuemin={MIN_W}\n aria-valuemax={MAX_W}\n tabIndex={0}\n onPointerDown={(e) => onResizeDown(e, col)}\n onDoubleClick={() =>\n setView((v) => {\n const widths = { ...v.widths };\n delete widths[col.key];\n return { widths };\n })\n }\n onKeyDown={(e) => {\n if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n e.preventDefault();\n const w = clamp(col.width + (e.key === \"ArrowRight\" ? 16 : -16), MIN_W, MAX_W);\n setView((v) => ({ widths: { ...v.widths, [col.key]: w } }));\n }}\n 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\"\n />\n </div>\n );\n }\n\n const header = (\n <div role=\"row\" aria-rowindex={1} className=\"sticky top-0 z-30 flex\" style={{ height: headH, width: totalW }}>\n <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 }}>\n <span className=\"sr-only\">Row</span>\n <span aria-hidden=\"true\">#</span>\n </div>\n {cols.slice(0, frozenN).map((col, c) => renderHeadCell(col, c, true))}\n {vCols.map((vc) => renderHeadCell(scrollCols[vc.index], vc.index + frozenN, false))}\n {canEditCols ? (\n <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 }}>\n <Popover open={addOpen} onOpenChange={setAddOpen} trigger={<Icon name=\"plus\" size={16} />} variant=\"quiet\" size=\"sm\" width=\"auto\" align=\"end\" label=\"Add a field\">\n <AddFieldPanel\n names={localCols.map((x) => x.name)}\n custom={custom}\n onAdd={(f) => {\n const col = { key: uid(\"field\"), ...f };\n addColumn(col);\n setAddOpen(false);\n // Show the new field: it is the last one.\n requestAnimationFrame(() => {\n if (scroller.current) scroller.current.scrollLeft = scroller.current.scrollWidth;\n });\n }}\n />\n </Popover>\n </div>\n ) : null}\n {colDrag ? (\n <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 }} />\n ) : null}\n </div>\n );\n\n // ── the body when there is nothing to show ───────────────────────────\n let emptyBody = null;\n if (!loading && viewRows.length === 0) {\n const blank = local.length === 0;\n emptyBody =\n blank && empty ? (\n empty\n ) : (\n <div className=\"grid max-w-sm justify-items-center gap-2 text-center\">\n <span className=\"grid size-10 place-items-center rounded-full bg-muted text-muted-foreground\">\n <Icon name={blank ? \"grid\" : \"filter\"} size={18} />\n </span>\n <p className=\"text-sm font-medium\">{blank ? (cols.length ? `No ${noun}s yet` : \"No fields yet\") : `No ${noun}s match`}</p>\n <p className=\"text-sm text-muted-foreground\">\n {blank\n ? cols.length\n ? canAdd\n ? `Add a ${noun}, or click here and paste rows copied from a spreadsheet.`\n : `When there are ${noun}s, they show here.`\n : \"Add a field to start the table.\"\n : \"Try another search, or clear the filters to see everything.\"}\n </p>\n <div className=\"mt-2\">\n {blank ? (\n canAdd && cols.length ? (\n <Button variant=\"primary\" icon=\"plus\" onClick={() => addRows([newRow()], { edit: true })}>\n Add a {noun}\n </Button>\n ) : null\n ) : (\n <Button\n onClick={() => {\n setSearch(\"\");\n setView({ filters: [] });\n }}\n >\n Clear filters\n </Button>\n )}\n </div>\n </div>\n );\n }\n\n // ── status line ──────────────────────────────────────────────────────\n const summary = range && !single ? summarize(range, viewRows, cols) : null;\n const activeProblem = A ? problems.get(`${viewRows[A.r]?.id}\\u0000${cols[A.c]?.key}`) : null;\n function nextProblem() {\n for (let r = 0; r < viewRows.length; r++) {\n for (let c = 0; c < cols.length; c++) {\n if (problems.has(`${viewRows[r].id}\\u0000${cols[c].key}`) && (!A || r > A.r || (r === A.r && c > A.c))) {\n select({ r, c });\n ensureVisible(r, c);\n focusGrid();\n return;\n }\n }\n }\n // wrap to the first\n for (let r = 0; r < viewRows.length; r++)\n for (let c = 0; c < cols.length; c++)\n if (problems.has(`${viewRows[r].id}\\u0000${cols[c].key}`)) {\n select({ r, c });\n ensureVisible(r, c);\n focusGrid();\n return;\n }\n }\n const fmtSum = (n) => (summary?.numCol ? typeOf(summary.numCol, custom).format(n, summary.numCol, ctx) : n.toLocaleString(locale, { maximumFractionDigits: 2 }));\n\n const btnSize = \"md\";\n const hiddenCount = view.hidden.filter((k) => localCols.some((c) => c.key === k)).length;\n const filterCount = view.filters.length;\n\n return (\n <div ref={rootRef} className={cx(\"grid min-w-0 gap-3\", className)}>\n {toolbar ? (\n <div className=\"flex flex-wrap items-center gap-2\">\n {toolbarStart}\n <div className=\"min-w-0 flex-1 max-sm:basis-full sm:max-w-72\">\n <InputGroup\n icon=\"search\"\n placeholder={`Search ${noun}s`}\n aria-label={`Search ${noun}s`}\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n action={search ? { label: \"Clear search\", icon: \"x\", onClick: () => setSearch(\"\") } : undefined}\n />\n </div>\n <Popover\n open={filterOpen}\n onOpenChange={setFilterOpen}\n variant={filterCount ? \"secondary\" : \"quiet\"}\n size={btnSize}\n width=\"auto\"\n align=\"start\"\n label=\"Filter\"\n trigger={\n <>\n <Icon name=\"filter\" />\n Filter\n {filterCount ? (\n <Badge variant=\"soft\" tone=\"primary\" className=\"tabular-nums\">\n {filterCount}\n </Badge>\n ) : null}\n </>\n }\n >\n <FilterPanel columns={orderedColumns(localCols, view)} filters={view.filters} ctx={ctx} custom={custom} onChange={(filters) => setView({ filters })} />\n </Popover>\n <Popover\n variant={view.sort.length ? \"secondary\" : \"quiet\"}\n size={btnSize}\n width=\"auto\"\n align=\"start\"\n label=\"Sort\"\n trigger={\n <>\n <Icon name=\"chevrons-up-down\" />\n Sort\n {view.sort.length ? (\n <Badge variant=\"soft\" tone=\"primary\" className=\"tabular-nums\">\n {view.sort.length}\n </Badge>\n ) : null}\n </>\n }\n >\n <SortPanel columns={orderedColumns(localCols, view)} sort={view.sort} custom={custom} onChange={(sort) => setView({ sort })} />\n </Popover>\n <Popover\n variant={hiddenCount ? \"secondary\" : \"quiet\"}\n size={btnSize}\n width=\"auto\"\n align=\"start\"\n label=\"Fields\"\n trigger={\n <>\n <Icon name={hiddenCount ? \"eye-off\" : \"eye\"} />\n {hiddenCount ? `${hiddenCount} hidden` : \"Fields\"}\n </>\n }\n >\n <FieldsPanel columns={orderedColumns(localCols, view)} hidden={view.hidden} custom={custom} onChange={(hidden) => setView({ hidden })} />\n </Popover>\n <div className=\"ml-auto flex items-center gap-1\">\n {!readOnly ? (\n <>\n <IconButton icon=\"undo\" label=\"Undo\" disabled={!undoStack.current.length} onClick={undo} className=\"max-sm:hidden\" />\n <IconButton icon=\"redo\" label=\"Redo\" disabled={!redoStack.current.length} onClick={redo} className=\"max-sm:hidden\" />\n </>\n ) : null}\n {toolbarEnd}\n {canAdd ? (\n narrow ? (\n <IconButton icon=\"plus\" variant=\"primary\" label={`Add a ${noun}`} onClick={() => addRows([newRow()], { edit: true })} />\n ) : (\n <Button variant=\"primary\" icon=\"plus\" onClick={() => addRows([newRow()], { edit: true })}>\n Add {noun}\n </Button>\n )\n ) : null}\n </div>\n </div>\n ) : null}\n\n <div className=\"relative min-w-0 overflow-hidden rounded-lg border border-border bg-background\" style={{ height }}>\n <ContextMenu items={menuItems} label=\"Cell actions\" className=\"h-full\">\n <div\n ref={scroller}\n role=\"grid\"\n aria-label={label}\n aria-rowcount={viewRows.length + 1}\n aria-colcount={cols.length + 1}\n aria-multiselectable=\"true\"\n aria-activedescendant={activeId}\n aria-busy={loading || undefined}\n tabIndex={0}\n className={cx(\"h-full overflow-auto overscroll-contain outline-none\", !A && \"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\")}\n onKeyDown={onKeyDown}\n onPointerDown={onPointerDown}\n onClick={onClick}\n onDoubleClick={onDoubleClick}\n onCopy={(e) => {\n if (editingRef.current || e.target !== scroller.current || !range) return;\n e.preventDefault();\n e.clipboardData.setData(\"text/plain\", copyText());\n }}\n onCut={(e) => {\n if (editingRef.current || e.target !== scroller.current || !range) return;\n e.preventDefault();\n e.clipboardData.setData(\"text/plain\", copyText());\n if (!readOnly) commitChanges(clearChanges(range, viewRows, cols, ctx, custom));\n }}\n onPaste={(e) => {\n if (editingRef.current || e.target !== scroller.current) return;\n e.preventDefault();\n pasteText(e.clipboardData.getData(\"text/plain\"));\n }}\n >\n <div className=\"relative select-none\" style={{ width: totalW, height: emptyBody ? undefined : contentH, minHeight: \"100%\" }}>\n {header}\n {loading\n ? Array.from({ length: 8 }, (_, i) => (\n <div key={i} className=\"absolute left-0 flex\" style={{ top: headH + i * rowH, height: rowH, width: totalW }}>\n <div className=\"sticky left-0 z-10 shrink-0 border-b border-r border-border bg-background\" style={{ width: gutterW }} />\n {cols.map((col) => (\n <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 }}>\n <Skeleton className={cx(\"h-3\", i % 3 === 0 ? \"w-3/5\" : i % 3 === 1 ? \"w-4/5\" : \"w-2/5\")} />\n </div>\n ))}\n </div>\n ))\n : vRows.map(renderRow)}\n {addLine && !emptyBody ? (\n <div className=\"absolute left-0 flex\" style={{ top: headH + bodyH, height: rowH, width: totalW }}>\n {/* 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. */}\n <button\n type=\"button\"\n tabIndex={-1}\n onClick={() => addRows([newRow()], { edit: true })}\n 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\"\n style={{ width: gutterW + frozenW }}\n >\n <Icon name=\"plus\" size={14} />\n Add {noun}\n </button>\n </div>\n ) : null}\n {emptyBody ? (\n <div className=\"sticky left-0 grid place-items-center px-4 py-10\" style={{ width: boxW ? boxW - 2 : \"100%\" }}>\n {emptyBody}\n </div>\n ) : null}\n </div>\n </div>\n </ContextMenu>\n </div>\n\n <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\">\n <div className=\"flex items-center gap-3\">\n <span className=\"tabular-nums\">\n {loading ? \"Loading…\" : filtered ? `${viewRows.length.toLocaleString()} of ${plural(local.length, noun)}` : plural(local.length, noun)}\n </span>\n {order.current.dirty && (view.sort.length || view.filters.length) ? (\n <Button variant=\"link\" size=\"inherit\" onClick={() => setResortTick((n) => n + 1)}>\n {view.sort.length ? \"Sort again\" : \"Filter again\"}\n </Button>\n ) : null}\n {problems.size ? (\n <Button variant=\"link\" size=\"inherit\" tone=\"muted\" onClick={nextProblem}>\n <span aria-hidden=\"true\" className=\"mr-1.5 inline-block size-1.5 rounded-full bg-destructive align-middle\" />\n {problems.size === 1 ? \"1 problem\" : `${problems.size.toLocaleString()} problems`}\n </Button>\n ) : null}\n </div>\n <div className=\"flex items-center gap-3\">\n {activeProblem ? <span className=\"text-destructive\">{activeProblem}</span> : null}\n {rowsMode ? (\n <>\n <span className=\"tabular-nums\">{plural(range.r1 - range.r0 + 1, noun)} selected</span>\n {onDeleteRows && !readOnly ? (\n <Button variant=\"link\" size=\"inherit\" tone=\"muted\" onClick={() => deleteRows(viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id))}>\n Delete\n </Button>\n ) : null}\n </>\n ) : summary ? (\n <span className=\"tabular-nums\">\n {summary.nums > 1 ? `Sum ${fmtSum(summary.sum)} · Average ${fmtSum(summary.avg)} · ` : \"\"}\n {summary.filled.toLocaleString()} of {summary.count.toLocaleString()} filled\n </span>\n ) : null}\n {!narrow && !coarse ? (\n <Popover trigger={<Icon name=\"keyboard\" size={16} />} variant=\"quiet\" size=\"sm\" width=\"auto\" align=\"end\" side=\"top\" label=\"Keyboard shortcuts\">\n <ShortcutsPanel />\n </Popover>\n ) : null}\n </div>\n </div>\n </div>\n );\n}\n\nfunction restoreRows(rs, removed) {\n const out = rs.slice();\n for (const { row, index } of [...removed].sort((a, b) => a.index - b.index)) {\n if (out.some((r) => r.id === row.id)) continue;\n out.splice(Math.min(index, out.length), 0, row);\n }\n return out;\n}\n\nfunction RenameInput({ name, onDone }) {\n const [text, setText] = useState(name);\n const input = useRef(null);\n const done = useRef(false);\n useLayoutEffect(() => {\n input.current?.focus();\n input.current?.select();\n }, []);\n const finish = (v) => {\n if (done.current) return;\n done.current = true;\n onDone(v);\n };\n return (\n // raw: a header-sized rename box inside a 40px column header; the kit's Input is a 44px field.\n <input\n ref={input}\n value={text}\n aria-label=\"Field name\"\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => {\n e.stopPropagation();\n if (e.key === \"Enter\") finish(text.trim());\n else if (e.key === \"Escape\") finish(null);\n }}\n onBlur={() => finish(text.trim())}\n 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\"\n />\n );\n}\n"
24
+ "content": "\"use client\";\n\n// The Spreadsheet: a table you edit like Sheets or Airtable. Rows and\n// columns come in as props; every edit goes out through an async callback\n// that answers { ok }, applied at once and rolled back if it says no.\n//\n// Virtualised both ways (@tanstack/react-virtual): only the cells in the\n// window are in the DOM, so 10,000 rows scroll like 10. Rows have one fixed\n// height, which keeps every position arithmetic — no measuring.\n//\n// Layout: one scroll box. Inside it, a content box the size of the whole\n// table; the header row is sticky to its top; each row is absolutely\n// placed at its offset. In a row (and in the header) the row-number\n// gutter and the frozen columns are in flow and sticky to the left; the\n// other cells are absolutely placed at their column's offset, so the\n// virtualiser may hand back any set of them.\nimport { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { defaultRangeExtractor, useVirtualizer } from \"@tanstack/react-virtual\";\nimport {\n Badge,\n Button,\n ContextMenu,\n Icon,\n IconButton,\n InputGroup,\n Menu,\n Popover,\n Skeleton,\n cx,\n toast,\n} from \"../../ui\";\nimport { CellValue, editorFor } from \"./cells\";\nimport { AddFieldPanel, FieldsPanel, FilterPanel, ShortcutsPanel, SortPanel, TYPE_ORDER, sortWords, uid } from \"./chrome\";\nimport {\n applyChanges,\n clamp,\n clearChanges,\n fillChanges,\n inRange,\n invertChanges,\n moveBy,\n normalizeView,\n orderedColumns,\n parseTSV,\n pasteChanges,\n processRows,\n rangeOf,\n rangeText,\n realChanges,\n summarize,\n toTSV,\n visibleColumns,\n} from \"./grid\";\nimport { convertValue, isBlank, parseCell, typeOf } from \"./types\";\n\nconst SIZES = {\n desk: { row: 36, head: 40, gutter: 64 },\n touch: { row: 44, head: 44, gutter: 52 },\n};\nconst ADD_COL = 48; // the \"+\" at the end of the header\nconst MIN_W = 64;\nconst MAX_W = 800;\nconst FROZEN_NARROW = 148; // a frozen column's widest on a phone\n\nconst plural = (n, noun) => `${n.toLocaleString()} ${n === 1 ? noun : `${noun}s`}`;\n\nasync function call(fn, ...args) {\n if (!fn) return { ok: true };\n try {\n const r = await fn(...args);\n return r ?? { ok: true };\n } catch (e) {\n return { ok: false, error: e?.message };\n }\n}\n\nconst isMac = () => typeof navigator !== \"undefined\" && /Mac|iP(hone|ad)/.test(navigator.platform || navigator.userAgent);\n\nexport function Spreadsheet({\n rows = [],\n columns = [],\n onChange,\n onAddRows,\n onDeleteRows,\n onColumnsChange,\n view: viewProp,\n defaultView,\n onViewChange,\n people,\n types,\n validate,\n readOnly = false,\n lockFields = false,\n onExpandRow,\n onSelectionChange,\n createRow,\n loading = false,\n label = \"Table\",\n noun = \"row\",\n height = 560,\n toolbar = true,\n toolbarStart,\n toolbarEnd,\n empty,\n locale,\n currency,\n today,\n className,\n ref,\n}) {\n const gid = useId().replace(/[^\\w-]/g, \"\");\n const custom = types;\n\n // ── data: the props, with optimistic edits laid on ───────────────────\n const [local, setLocal] = useState(rows);\n const [seenRows, setSeenRows] = useState(rows);\n if (rows !== seenRows) {\n setSeenRows(rows);\n setLocal(rows);\n }\n const [localCols, setLocalCols] = useState(columns);\n const [seenCols, setSeenCols] = useState(columns);\n if (columns !== seenCols) {\n setSeenCols(columns);\n setLocalCols(columns);\n }\n const localRef = useRef(local);\n localRef.current = local;\n const colsRef = useRef(localCols);\n colsRef.current = localCols;\n\n // ── the view ─────────────────────────────────────────────────────────\n const [ownView, setOwnView] = useState(() => normalizeView(defaultView));\n const view = useMemo(() => normalizeView(viewProp ?? ownView), [viewProp, ownView]);\n const viewRef = useRef(view);\n viewRef.current = view;\n const setView = useCallback(\n (patch) => {\n const next = { ...viewRef.current, ...(typeof patch === \"function\" ? patch(viewRef.current) : patch) };\n viewRef.current = next;\n if (viewProp === undefined) setOwnView(next);\n onViewChange?.(next);\n },\n [viewProp, onViewChange],\n );\n const [search, setSearch] = useState(\"\");\n\n // ── the box: phone or desk ───────────────────────────────────────────\n const rootRef = useRef(null);\n const scroller = useRef(null);\n const [boxW, setBoxW] = useState(null);\n const [coarse, setCoarse] = useState(false);\n useLayoutEffect(() => {\n const el = rootRef.current;\n if (!el) return;\n setBoxW(el.clientWidth);\n // The new width is taken in the next frame, never inside the observer's\n // own delivery: a change made there that touches anything observed\n // (the virtualisers watch this grid too) makes the browser report\n // \"ResizeObserver loop completed with undelivered notifications\".\n let frame = 0;\n const ro = new ResizeObserver(() => {\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(() => setBoxW(el.clientWidth));\n });\n ro.observe(el);\n const mq = window.matchMedia?.(\"(pointer: coarse)\");\n const onMq = () => setCoarse(Boolean(mq?.matches));\n onMq();\n mq?.addEventListener?.(\"change\", onMq);\n return () => {\n cancelAnimationFrame(frame);\n ro.disconnect();\n mq?.removeEventListener?.(\"change\", onMq);\n };\n }, []);\n const narrow = boxW != null && boxW < 640;\n const size = narrow || coarse ? SIZES.touch : SIZES.desk;\n const rowH = size.row;\n const headH = size.head;\n const gutterW = size.gutter;\n\n const ctx = useMemo(() => ({ people: people ?? [], locale, currency }), [people, locale, currency]);\n const canEditCols = Boolean(onColumnsChange) && !readOnly && !lockFields;\n const canAdd = Boolean(onAddRows) && !readOnly;\n\n // ── columns in view ──────────────────────────────────────────────────\n const [resizing, setResizing] = useState(null); // { key, width } while a handle is dragged\n const cols = useMemo(() => {\n const list = visibleColumns(localCols, view, custom, narrow);\n return list.map((c, i) => {\n let w = resizing?.key === c.key ? resizing.width : c.width;\n if (narrow && i < view.frozen) w = Math.min(w, FROZEN_NARROW);\n return w === c.width ? c : { ...c, width: w };\n });\n }, [localCols, view, custom, narrow, resizing]);\n const frozenN = Math.min(view.frozen, cols.length);\n const colLeft = useMemo(() => {\n const out = [];\n let x = gutterW;\n for (const c of cols) {\n out.push(x);\n x += c.width;\n }\n out.push(x);\n return out;\n }, [cols, gutterW]);\n const frozenW = colLeft[frozenN] - gutterW;\n const totalW = colLeft[cols.length] + (canEditCols ? ADD_COL : 0);\n const colIndex = useMemo(() => new Map(cols.map((c, i) => [c.key, i])), [cols]);\n const typeAt = (c) => typeOf(cols[c], custom);\n\n // ── rows in view: filtered and sorted, then HELD ──────────────────────\n // An edit does not re-sort or re-filter: the row stays under the cursor\n // (as in Airtable) until the view changes or \"Sort again\" is pressed.\n // Rows added here go where they were added; rows that arrive from the\n // outside re-run the view.\n const [resortTick, setResortTick] = useState(0);\n const order = useRef({ key: null, ids: [], dirty: false });\n const addedHere = useRef(new Set());\n const hints = useRef(new Map()); // new row id → { after, before }\n const viewRows = useMemo(() => {\n const byId = new Map(local.map((r) => [r.id, r]));\n const key = [view.sort, view.filters, view.hidden, search, localCols, custom, ctx, resortTick];\n const prev = order.current;\n let ids = null;\n if (prev.key && key.every((k, i) => k === prev.key[i])) {\n const known = new Set(prev.ids);\n const fresh = [];\n for (const r of local) if (!known.has(r.id)) fresh.push(r.id);\n if (fresh.every((id) => addedHere.current.has(id)) && prev.all?.size !== undefined) {\n ids = prev.ids.filter((id) => byId.has(id));\n for (const id of fresh) {\n const h = hints.current.get(id);\n const at = h ? ids.indexOf(h.ref) : -1;\n if (at < 0) ids.push(id);\n else ids.splice(h.before ? at : at + 1, 0, id);\n }\n }\n }\n if (!ids) {\n ids = processRows(local, localCols, view, { search, ctx, custom }).map((r) => r.id);\n order.current = { key, ids, dirty: false, all: new Set(ids) };\n } else order.current = { ...prev, key, ids };\n return ids.map((id) => byId.get(id));\n }, [local, localCols, view, search, custom, ctx, resortTick]);\n const rowIndex = useMemo(() => new Map(viewRows.map((r, i) => [r.id, i])), [viewRows]);\n const rowById = useMemo(() => new Map(local.map((r) => [r.id, r])), [local]);\n 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() !== \"\";\n\n // ── problems (validate + required) ────────────────────────────────────\n const problems = useMemo(() => {\n const out = new Map();\n const needs = localCols.filter((c) => c.required);\n if (!validate && !needs.length) return out;\n const check = validate ? localCols : needs;\n for (const r of local) {\n for (const c of check) {\n let msg = null;\n if (c.required && typeOf(c, custom).empty(r[c.key])) msg = `${c.name} is required`;\n else if (validate) msg = validate(r, c) || null;\n if (msg) out.set(`${r.id}\\u0000${c.key}`, msg);\n }\n }\n return out;\n }, [local, localCols, validate, custom]);\n\n // ── selection ────────────────────────────────────────────────────────\n // By row id and column key, so it follows a row through a sort.\n const [sel, setSel] = useState(null); // { a: {id, key}, f: {id, key}, rows?: true }\n const [editing, setEditing] = useState(null); // { id, key, initial }\n const editingRef = useRef(editing);\n editingRef.current = editing;\n 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;\n 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;\n const range = A ? rangeOf(A, F) : null;\n const single = range && range.r0 === range.r1 && range.c0 === range.c1;\n const rowsMode = Boolean(sel?.rows && range);\n const stateRef = useRef({});\n stateRef.current = { A, F, range, viewRows, cols, rowsMode };\n\n const at = (r, c) => ({ id: viewRows[r]?.id, key: cols[c]?.key });\n const select = useCallback(\n (a, f = a, opts = {}) => {\n const { viewRows: vr, cols: cs } = stateRef.current;\n if (!vr.length || !cs.length) return setSel(null);\n const ca = { r: clamp(a.r, 0, vr.length - 1), c: clamp(a.c, 0, cs.length - 1) };\n const cf = { r: clamp(f.r, 0, vr.length - 1), c: clamp(f.c, 0, cs.length - 1) };\n 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 });\n },\n [],\n );\n\n const focusGrid = useCallback(() => scroller.current?.focus({ preventScroll: true }), []);\n\n // Keep a cell in the window: under the sticky header, right of the\n // frozen columns.\n const ensureVisible = useCallback(\n (r, c) => {\n const el = scroller.current;\n if (!el) return;\n if (r != null && r >= 0) {\n const top = headH + r * rowH;\n if (top < el.scrollTop + headH) el.scrollTop = top - headH;\n else if (top + rowH > el.scrollTop + el.clientHeight) el.scrollTop = top + rowH - el.clientHeight;\n }\n if (c != null && c >= frozenN && c < cols.length) {\n const left = colLeft[c];\n const right = colLeft[c + 1];\n const fixed = gutterW + frozenW;\n if (left < el.scrollLeft + fixed) el.scrollLeft = left - fixed;\n else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = right - el.clientWidth;\n }\n },\n [headH, rowH, frozenN, cols.length, colLeft, gutterW, frozenW],\n );\n\n // A row added here is selected and shown once it renders.\n const pendingFocus = useRef(null);\n useEffect(() => {\n const p = pendingFocus.current;\n if (!p || !rowIndex.has(p.id)) return;\n pendingFocus.current = null;\n const r = rowIndex.get(p.id);\n select({ r, c: 0 });\n ensureVisible(r, 0);\n if (p.edit && cols[0]) setEditing({ id: p.id, key: cols[0].key, initial: null });\n else focusGrid();\n }, [rowIndex, select, ensureVisible, focusGrid, cols]);\n\n useEffect(() => {\n if (!onSelectionChange) return;\n if (!range) return onSelectionChange(null);\n onSelectionChange({\n active: { rowId: viewRows[A.r]?.id, key: cols[A.c]?.key },\n rowIds: viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id),\n keys: cols.slice(range.c0, range.c1 + 1).map((c) => c.key),\n });\n // The ids, not the objects: a value edit is not a selection change.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [sel, viewRows.length, cols.length]);\n\n // ── history ──────────────────────────────────────────────────────────\n const undoStack = useRef([]);\n const redoStack = useRef([]);\n const [, setHist] = useState(0);\n const bump = () => setHist((n) => n + 1);\n\n // ── saving ───────────────────────────────────────────────────────────\n const updateColumns = useCallback(\n async (next, reason) => {\n const prev = colsRef.current;\n colsRef.current = next;\n setLocalCols(next);\n const res = await call(onColumnsChange, next, { reason });\n if (!res.ok) {\n colsRef.current = prev;\n setLocalCols(prev);\n toast.error(\"Couldn’t change the fields\", { description: res.error ?? \"Nothing was changed. Try again.\" });\n }\n return res;\n },\n [onColumnsChange],\n );\n\n const commitChanges = useCallback(\n async (input, { creates, history = true } = {}) => {\n const changes = realChanges(input);\n if (creates?.size) {\n const next = colsRef.current.map((c) => (creates.has(c.key) ? { ...c, options: [...(c.options ?? []), ...creates.get(c.key)] } : c));\n updateColumns(next, \"options\");\n }\n if (!changes.length) return { ok: true };\n setLocal((rs) => applyChanges(rs, changes));\n const v = viewRef.current;\n const watched = new Set([...v.sort.map((s) => s.key), ...v.filters.map((f) => f.key)]);\n if (changes.some((ch) => watched.has(ch.key))) order.current.dirty = true;\n const step = { kind: \"cells\", changes };\n if (history) {\n undoStack.current.push(step);\n if (undoStack.current.length > 100) undoStack.current.shift();\n redoStack.current = [];\n bump();\n }\n const res = await call(onChange, changes);\n if (!res.ok) {\n setLocal((rs) => applyChanges(rs, changes, \"prev\"));\n undoStack.current = undoStack.current.filter((s) => s !== step);\n bump();\n toast.error(changes.length === 1 ? \"Couldn’t save that change\" : `Couldn’t save ${changes.length} changes`, {\n description: res.error ?? \"It was put back as it was. Try again.\",\n });\n }\n return res;\n },\n [onChange, updateColumns],\n );\n\n const newRow = useCallback(\n (values = {}) => {\n const base = createRow ? createRow() : {};\n return { ...base, id: base.id ?? uid(\"row\"), ...values };\n },\n [createRow],\n );\n\n const addRows = useCallback(\n async (list, { index, hint, edit = false, history = true, restore = false } = {}) => {\n if (!list.length) return { ok: true };\n for (const r of list) addedHere.current.add(r.id);\n if (hint) list.forEach((r, i) => hints.current.set(r.id, i === 0 ? hint : { ref: list[i - 1].id }));\n setLocal((rs) => (index == null ? [...rs, ...list] : [...rs.slice(0, index), ...list, ...rs.slice(index)]));\n pendingFocus.current = restore ? null : { id: list[0].id, edit };\n const step = { kind: \"add\", rows: list, index };\n if (history) {\n undoStack.current.push(step);\n redoStack.current = [];\n bump();\n }\n const res = await call(onAddRows, list, { index, restore });\n if (!res.ok) {\n const gone = new Set(list.map((r) => r.id));\n setLocal((rs) => rs.filter((r) => !gone.has(r.id)));\n undoStack.current = undoStack.current.filter((s) => s !== step);\n bump();\n toast.error(list.length === 1 ? `Couldn’t add the ${noun}` : `Couldn’t add ${plural(list.length, noun)}`, { description: res.error ?? \"Try again.\" });\n } else if (Array.isArray(res.rows)) {\n // The server named them: swap the ids we made up for its own.\n const swap = new Map(list.map((r, i) => [r.id, res.rows[i]]).filter(([, x]) => x));\n if (swap.size) {\n for (const x of swap.values()) addedHere.current.add(x.id);\n setLocal((rs) => rs.map((r) => (swap.has(r.id) ? { ...r, ...swap.get(r.id) } : r)));\n order.current = { ...order.current, ids: order.current.ids.map((id) => (swap.has(id) ? swap.get(id).id ?? id : id)) };\n 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));\n }\n }\n return res;\n },\n [onAddRows, noun],\n );\n\n const deleteRows = useCallback(\n async (ids, { history = true, quiet = false } = {}) => {\n const gone = new Set(ids);\n const removed = [];\n localRef.current.forEach((r, i) => {\n if (gone.has(r.id)) removed.push({ row: r, index: i });\n });\n if (!removed.length) return { ok: true };\n setLocal((rs) => rs.filter((r) => !gone.has(r.id)));\n setSel(null);\n const step = { kind: \"delete\", removed };\n if (history) {\n undoStack.current.push(step);\n redoStack.current = [];\n bump();\n }\n const res = await call(onDeleteRows, ids);\n if (!res.ok) {\n setLocal((rs) => restoreRows(rs, removed));\n undoStack.current = undoStack.current.filter((s) => s !== step);\n bump();\n toast.error(`Couldn’t delete ${removed.length === 1 ? `the ${noun}` : plural(removed.length, noun)}`, { description: res.error ?? \"Nothing was deleted. Try again.\" });\n } else if (!quiet) {\n toast(removed.length === 1 ? `Deleted 1 ${noun}` : `Deleted ${plural(removed.length, noun)}`, {\n action: { label: \"Undo\", onClick: () => undoRef.current?.() },\n });\n }\n return res;\n },\n [onDeleteRows, noun],\n );\n\n async function undo() {\n const step = undoStack.current.pop();\n if (!step) return;\n bump();\n if (step.kind === \"cells\") await commitChanges(invertChanges(step.changes), { history: false });\n else if (step.kind === \"add\") await deleteRows(step.rows.map((r) => r.id), { history: false, quiet: true });\n else if (step.kind === \"delete\") {\n for (const { row } of step.removed) addedHere.current.add(row.id);\n setLocal((rs) => restoreRows(rs, step.removed));\n await call(onAddRows, step.removed.map((x) => x.row), { restore: true, index: step.removed[0].index });\n }\n redoStack.current.push(step);\n bump();\n focusGrid();\n }\n async function redo() {\n const step = redoStack.current.pop();\n if (!step) return;\n bump();\n if (step.kind === \"cells\") await commitChanges(step.changes, { history: false });\n else if (step.kind === \"add\") await addRows(step.rows, { index: step.index, history: false, restore: true });\n else if (step.kind === \"delete\") await deleteRows(step.removed.map((x) => x.row.id), { history: false, quiet: true });\n undoStack.current.push(step);\n bump();\n focusGrid();\n }\n const undoRef = useRef(undo);\n undoRef.current = undo;\n\n // ── editing ──────────────────────────────────────────────────────────\n const editable = (c) => !readOnly && cols[c] && !cols[c].readOnly && !cols[c].computed;\n\n function startEdit(r, c, initial = null) {\n if (!editable(c) || !viewRows[r]) return;\n const t = typeAt(c);\n const row = viewRows[r];\n if (t.editor === \"toggle\") return commitChanges([{ rowId: row.id, key: cols[c].key, value: !row[cols[c].key], prev: row[cols[c].key] }]);\n if (!editorFor(t)) return;\n select({ r, c });\n ensureVisible(r, c);\n setEditing({ id: row.id, key: cols[c].key, initial });\n }\n\n function moveActive(move) {\n const s = stateRef.current;\n if (!s.A) return;\n const d = { down: [1, 0], up: [-1, 0], right: [0, 1], left: [0, -1] }[move];\n if (!d) return;\n 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) };\n select(next);\n ensureVisible(next.r, next.c);\n }\n\n function commitEdit(value, move, extra) {\n const ed = editingRef.current;\n if (!ed) return;\n const row = rowById.get(ed.id);\n const creates = extra?.create?.length ? new Map([[ed.key, extra.create]]) : null;\n setEditing(null);\n focusGrid();\n commitChanges([{ rowId: ed.id, key: ed.key, value, prev: row?.[ed.key] }], { creates });\n if (move) moveActive(move);\n }\n function cancelEdit() {\n setEditing(null);\n focusGrid();\n }\n\n // ── clipboard ────────────────────────────────────────────────────────\n function copyText() {\n if (!range) return \"\";\n return toTSV(rangeText(range, viewRows, cols, ctx, custom));\n }\n\n function pasteText(text) {\n const matrix = parseTSV(text);\n if (!matrix.length || readOnly) return;\n // An empty table: the paste becomes its rows.\n const target = range ?? { r0: viewRows.length, r1: viewRows.length, c0: 0, c1: 0 };\n const { changes, errors, creates, extra } = pasteChanges(matrix, target, viewRows, cols, ctx, custom);\n commitChanges(changes, { creates });\n if (extra.length && canAdd) {\n const made = extra.map((line) => {\n const values = {};\n line.forEach((text, j) => {\n const col = cols[target.c0 + j];\n if (!col || col.readOnly) return;\n const res = parseCell(text, col, ctx, custom);\n if (!(\"error\" in res) && !res.create) values[col.key] = res.value;\n });\n return newRow(values);\n });\n addRows(made);\n }\n const h = matrix.length === 1 && matrix[0].length === 1 ? target.r1 - target.r0 : matrix.length - 1;\n const w = matrix.length === 1 && matrix[0].length === 1 ? target.c1 - target.c0 : Math.max(...matrix.map((l) => l.length)) - 1;\n 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) });\n if (errors.length) {\n toast.warning(errors.length === 1 ? \"1 cell was skipped\" : `${errors.length} cells were skipped`, {\n description: `${errors[0].error}${errors.length > 1 ? \", and more\" : \"\"}. The rest were pasted.`,\n });\n }\n }\n\n // ── keys ─────────────────────────────────────────────────────────────\n function onKeyDown(e) {\n if (editingRef.current) return;\n if (e.target !== scroller.current) return; // a header menu, an input\n const mod = e.metaKey || e.ctrlKey;\n const k = e.key;\n if (!viewRows.length || !cols.length) return;\n const s = A ?? null;\n\n if (mod && (k === \"z\" || k === \"Z\")) {\n e.preventDefault();\n return e.shiftKey ? redo() : undo();\n }\n if (mod && (k === \"y\" || k === \"Y\")) {\n e.preventDefault();\n return redo();\n }\n if (mod && (k === \"a\" || k === \"A\")) {\n e.preventDefault();\n return select({ r: 0, c: 0 }, { r: viewRows.length - 1, c: cols.length - 1 });\n }\n if (!s) {\n if (k.startsWith(\"Arrow\") || k === \"Tab\" || k === \"Enter\") {\n e.preventDefault();\n select({ r: 0, c: 0 });\n ensureVisible(0, 0);\n }\n return;\n }\n const page = Math.max(1, Math.floor(((scroller.current?.clientHeight ?? 400) - headH) / rowH) - 1);\n const moved = moveBy(k, e.shiftKey ? F : s, { rows: viewRows.length, cols: cols.length, page, jump: mod });\n if (moved) {\n e.preventDefault();\n if (e.shiftKey) select(s, moved, { rows: rowsMode && moved.c === F.c ? true : undefined });\n else select(moved);\n ensureVisible(moved.r, moved.c);\n return;\n }\n if (k === \"Tab\") {\n e.preventDefault();\n let { r, c } = s;\n if (e.shiftKey) {\n if (c > 0) c--;\n else if (r > 0) [r, c] = [r - 1, cols.length - 1];\n } else if (c < cols.length - 1) c++;\n else if (r < viewRows.length - 1) [r, c] = [r + 1, 0];\n select({ r, c });\n ensureVisible(r, c);\n return;\n }\n if (k === \"Escape\") {\n if (!single) {\n e.preventDefault();\n select(s);\n }\n return;\n }\n if (k === \"Enter\" || k === \"F2\") {\n e.preventDefault();\n if (k === \"Enter\" && mod && onExpandRow) return onExpandRow(viewRows[s.r]);\n return startEdit(s.r, s.c);\n }\n if (k === \"Delete\" || k === \"Backspace\") {\n e.preventDefault();\n if (readOnly) return;\n return commitChanges(clearChanges(range, viewRows, cols, ctx, custom));\n }\n if (k === \" \" && e.shiftKey) {\n e.preventDefault();\n return select({ r: range.r0, c: 0 }, { r: range.r1, c: cols.length - 1 }, { rows: true });\n }\n if (k === \" \" && mod) {\n e.preventDefault();\n return select({ r: 0, c: range.c0 }, { r: viewRows.length - 1, c: range.c1 });\n }\n if (mod && (k === \"d\" || k === \"D\")) {\n e.preventDefault();\n if (readOnly) return;\n // Several rows: the first row fills the rest. One row: the row above\n // fills it, as Sheets does.\n if (range.r1 > range.r0) return commitChanges(fillChanges({ ...range, r1: range.r0 }, range.r1, viewRows, cols));\n if (range.r0 > 0) return commitChanges(fillChanges({ ...range, r0: range.r0 - 1, r1: range.r0 - 1 }, range.r0, viewRows, cols));\n return;\n }\n if (k === \" \") {\n e.preventDefault();\n const t = typeAt(s.c);\n if (t.editor === \"toggle\" && !readOnly) {\n const first = viewRows[range.r0]?.[cols[s.c].key];\n const changes = [];\n for (let r = range.r0; r <= range.r1; r++)\n for (let c = range.c0; c <= range.c1; c++) {\n if (typeAt(c).editor !== \"toggle\" || !editable(c)) continue;\n changes.push({ rowId: viewRows[r].id, key: cols[c].key, value: !first, prev: viewRows[r][cols[c].key] });\n }\n return commitChanges(changes);\n }\n if (onExpandRow) return onExpandRow(viewRows[s.r]);\n return startEdit(s.r, s.c, \" \");\n }\n // A printable key starts an edit with that key as the text.\n if (k.length === 1 && !mod && !e.altKey) {\n const t = typeAt(s.c);\n if (t.editor === \"rating\" && /[0-9]/.test(k) && editable(s.c)) {\n e.preventDefault();\n const res = parseCell(k, cols[s.c], ctx, custom);\n 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] }]);\n return;\n }\n if (editorFor(t) && editable(s.c)) {\n e.preventDefault();\n startEdit(s.r, s.c, k);\n }\n }\n }\n\n // ── pointer ──────────────────────────────────────────────────────────\n const drag = useRef(null); // { mode: \"select\" | \"fill\", ... }\n const lastPointer = useRef(\"mouse\");\n const [fillTo, setFillToState] = useState(null);\n const fillRef = useRef(null);\n const setFillTo = (v) => {\n fillRef.current = v;\n setFillToState(v);\n };\n\n function cellFrom(target) {\n const el = target.closest?.(\"[data-cell]\");\n if (!el || !scroller.current?.contains(el)) return null;\n return { r: Number(el.dataset.r), c: Number(el.dataset.c) };\n }\n\n function rowAtY(clientY) {\n const el = scroller.current;\n const rect = el.getBoundingClientRect();\n return clamp(Math.floor((clientY - rect.top + el.scrollTop - headH) / rowH), 0, viewRows.length - 1);\n }\n\n function autoScroll(e) {\n const el = scroller.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (e.clientY > rect.bottom - 28) el.scrollTop += 18;\n else if (e.clientY < rect.top + headH + 12) el.scrollTop -= 18;\n if (e.clientX > rect.right - 28) el.scrollLeft += 18;\n else if (e.clientX < rect.left + gutterW + frozenW + 12 && el.scrollLeft > 0) el.scrollLeft -= 18;\n }\n\n useEffect(() => {\n function move(e) {\n const d = drag.current;\n if (!d) return;\n autoScroll(e);\n if (d.mode === \"fill\") setFillTo(rowAtY(e.clientY));\n else if (d.mode === \"select\") {\n const hit = cellFrom(document.elementFromPoint(e.clientX, e.clientY) ?? document.body);\n if (hit && (hit.r !== d.last?.r || hit.c !== d.last?.c)) {\n d.last = hit;\n select(d.anchor, hit);\n }\n } else if (d.mode === \"rows\") {\n const r = rowAtY(e.clientY);\n select({ r: d.anchor, c: 0 }, { r, c: stateRef.current.cols.length - 1 }, { rows: true });\n }\n }\n function up() {\n const d = drag.current;\n drag.current = null;\n if (d?.mode === \"fill\") {\n const to = fillRef.current;\n setFillTo(null);\n const { range: g, viewRows: vr, cols: cs } = stateRef.current;\n if (g && to != null && (to > g.r1 || to < g.r0)) {\n commitChanges(fillChanges(g, to, vr, cs));\n select({ r: Math.min(g.r0, to), c: g.c0 }, { r: Math.max(g.r1, to), c: g.c1 });\n }\n }\n }\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n return () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [select, commitChanges, headH, rowH, viewRows.length, gutterW, frozenW]);\n\n function onPointerDown(e) {\n lastPointer.current = e.pointerType || \"mouse\";\n const t = e.target;\n if (t.closest?.(\"[data-fill]\")) {\n e.preventDefault();\n e.stopPropagation();\n drag.current = { mode: \"fill\" };\n setFillTo(range?.r1 ?? null);\n return;\n }\n const gutter = t.closest?.(\"[data-gutter]\");\n if (gutter && !t.closest(\"[data-expand]\")) {\n const r = Number(gutter.dataset.r);\n if (e.button !== 0 && inRange(range ?? { r0: -1, r1: -1, c0: 0, c1: 0 }, r, 0)) return;\n e.preventDefault();\n focusGrid();\n const anchor = e.shiftKey && range ? range.r0 : r;\n select({ r: anchor, c: 0 }, { r, c: cols.length - 1 }, { rows: true });\n if (e.button === 0 && e.pointerType === \"mouse\") drag.current = { mode: \"rows\", anchor };\n return;\n }\n const hit = cellFrom(t);\n if (!hit) return;\n const ed = editingRef.current;\n if (ed && ed.id === viewRows[hit.r]?.id && ed.key === cols[hit.c]?.key) return;\n const secondary = e.button === 2 || (e.button === 0 && e.ctrlKey && isMac());\n if (secondary) {\n if (!range || !inRange(range, hit.r, hit.c)) select(hit);\n return;\n }\n if (e.button !== 0) return;\n if (t.closest?.(\"[data-toggle]\") && editable(hit.c)) {\n e.preventDefault();\n focusGrid();\n select(hit);\n const row = viewRows[hit.r];\n const key = cols[hit.c].key;\n commitChanges([{ rowId: row.id, key, value: !row[key], prev: row[key] }]);\n return;\n }\n if (t.closest?.(\"input, button, a, [role=radio], label\")) return; // a rating star, a link\n if (e.pointerType !== \"mouse\") return; // touch: a tap is handled on click, a drag scrolls\n e.preventDefault();\n focusGrid();\n if (e.shiftKey && A) select(A, hit);\n else select(hit);\n drag.current = { mode: \"select\", anchor: e.shiftKey && A ? A : hit, last: hit };\n }\n\n const isEditingAt = (hit) => {\n const ed = editingRef.current;\n return Boolean(ed && ed.id === viewRows[hit.r]?.id && ed.key === cols[hit.c]?.key);\n };\n\n function onClick(e) {\n if (lastPointer.current === \"mouse\") return;\n const hit = cellFrom(e.target);\n if (!hit || isEditingAt(hit) || e.target.closest?.(\"input, button, a, [role=radio], label, [data-toggle]\")) return;\n focusGrid();\n if (A && single && A.r === hit.r && A.c === hit.c) startEdit(hit.r, hit.c);\n else select(hit);\n }\n\n function onDoubleClick(e) {\n const hit = cellFrom(e.target);\n if (!hit || isEditingAt(hit) || e.target.closest?.(\"[data-toggle], a, [role=radio]\")) return;\n startEdit(hit.r, hit.c);\n }\n\n // ── column header actions ────────────────────────────────────────────\n const [renaming, setRenaming] = useState(null);\n const [colDrag, setColDrag] = useState(null); // { from, to }\n const [filterOpen, setFilterOpen] = useState(false);\n const [addOpen, setAddOpen] = useState(false);\n\n function reorder(fromKey, toIndex) {\n const all = orderedColumns(colsRef.current, viewRef.current).map((c) => c.key);\n const shown = cols.map((c) => c.key);\n const targetKey = shown[toIndex] ?? null; // insert before this one (null: at the end)\n const without = all.filter((k) => k !== fromKey);\n const at = targetKey ? without.indexOf(targetKey) : without.length;\n without.splice(at < 0 ? without.length : at, 0, fromKey);\n setView({ order: without });\n }\n\n function onHeadPointerDown(e, c) {\n if (e.button !== 0 || e.pointerType !== \"mouse\" || e.target.closest(\"button, input, [data-resize]\")) return;\n e.preventDefault();\n const x0 = e.clientX;\n let moved = false;\n const el = scroller.current;\n const target = (clientX) => {\n const rect = el.getBoundingClientRect();\n const x = clientX - rect.left + el.scrollLeft;\n let i = 0;\n while (i < cols.length && colLeft[i] + cols[i].width / 2 < x) i++;\n return i;\n };\n const move = (ev) => {\n if (!moved && Math.abs(ev.clientX - x0) < 5) return;\n moved = true;\n autoScroll(ev);\n setColDrag({ from: c, to: target(ev.clientX) });\n };\n const up = (ev) => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n if (ev.type === \"pointercancel\") return setColDrag(null);\n if (moved) {\n const to = target(ev.clientX);\n setColDrag(null);\n if (to !== c && to !== c + 1) reorder(cols[c].key, to);\n } else {\n focusGrid();\n if (!viewRows.length) return;\n if (ev.shiftKey && A) select({ r: 0, c: A.c }, { r: viewRows.length - 1, c });\n else select({ r: 0, c }, { r: viewRows.length - 1, c });\n }\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n }\n\n function onResizeDown(e, col) {\n e.preventDefault();\n e.stopPropagation();\n const x0 = e.clientX;\n const w0 = col.width;\n let last = w0;\n e.currentTarget.setPointerCapture?.(e.pointerId);\n const move = (ev) => {\n last = clamp(Math.round(w0 + ev.clientX - x0), MIN_W, MAX_W);\n setResizing({ key: col.key, width: last });\n };\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n setResizing(null);\n if (last !== w0) setView((v) => ({ widths: { ...v.widths, [col.key]: last } }));\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n }\n\n async function changeType(col, typeName) {\n if (col.type === typeName) return;\n const before = colsRef.current;\n let next = { ...col, type: typeName };\n const t = typeOf(next, custom);\n const changes = [];\n const creates = [];\n for (const r of localRef.current) {\n const v = r[col.key];\n if (typeOf(col, custom).empty(v) && t.name !== \"checkbox\") continue;\n if (t.editor === \"select\") {\n const res = parseCell(typeOf(col, custom).toText(v, col, ctx), { ...next, options: [...(next.options ?? []), ...creates] }, ctx, custom);\n if (\"error\" in res) continue;\n if (res.create) creates.push(...res.create);\n changes.push({ rowId: r.id, key: col.key, value: res.value, prev: v });\n } else changes.push({ rowId: r.id, key: col.key, value: convertValue(v, col, next, ctx, custom), prev: v });\n }\n if (creates.length) next = { ...next, options: [...(next.options ?? []), ...creates] };\n const res = await updateColumns(\n before.map((c) => (c.key === col.key ? next : c)),\n \"type\",\n );\n if (!res.ok) return;\n await commitChanges(changes, { history: false });\n toast(`${col.name} is now ${t.label.toLowerCase()}`, {\n action: {\n label: \"Undo\",\n onClick: () => {\n updateColumns(before, \"type\");\n commitChanges(invertChanges(realChanges(changes)), { history: false });\n },\n },\n });\n }\n\n function insertColumn(c, side) {\n const names = new Set(colsRef.current.map((x) => x.name));\n let n = colsRef.current.length + 1;\n while (names.has(`Field ${n}`)) n++;\n const col = { key: uid(\"field\"), name: `Field ${n}`, type: \"text\" };\n addColumn(col, side === \"left\" ? c : c + 1);\n setRenaming(col.key);\n }\n\n function addColumn(col, visibleIndex = cols.length) {\n const all = colsRef.current;\n updateColumns([...all, col], \"add\");\n const order = orderedColumns(all, viewRef.current).map((x) => x.key);\n const beforeKey = cols[visibleIndex]?.key;\n const i = beforeKey ? order.indexOf(beforeKey) : order.length;\n order.splice(i < 0 ? order.length : i, 0, col.key);\n setView({ order });\n }\n\n function deleteColumn(col) {\n const before = colsRef.current;\n updateColumns(\n before.filter((c) => c.key !== col.key),\n \"delete\",\n );\n toast(`Deleted ${col.name}`, { action: { label: \"Undo\", onClick: () => updateColumns(before, \"restore\") } });\n }\n\n function headerItems(col, c) {\n const t = typeOf(col, custom);\n const words = sortWords(t);\n const sorted = view.sort.find((s) => s.key === col.key);\n return [\n { label: `Sort ${words.asc}`, icon: \"arrow-up\", onSelect: () => setView({ sort: [{ key: col.key, dir: \"asc\" }] }) },\n { label: `Sort ${words.desc}`, icon: \"arrow-down\", onSelect: () => setView({ sort: [{ key: col.key, dir: \"desc\" }] }) },\n sorted && { label: \"Remove sort\", icon: \"x\", onSelect: () => setView((v) => ({ sort: v.sort.filter((s) => s.key !== col.key) })) },\n {\n label: \"Filter by this field\",\n icon: \"filter\",\n onSelect: () => {\n setView((v) => ({ filters: [...v.filters, { id: uid(\"f\"), key: col.key, op: t.allFilters[0].id, value: \"\" }] }));\n setFilterOpen(true);\n },\n },\n { type: \"separator\" },\n canEditCols && { label: \"Rename\", icon: \"pencil\", onSelect: () => setRenaming(col.key) },\n canEditCols &&\n !col.computed && {\n label: \"Field type\",\n icon: t.icon,\n items: [\n {\n type: \"radio\",\n value: col.type ?? \"text\",\n onValueChange: (v) => changeType(col, v),\n options: [...TYPE_ORDER, ...Object.keys(custom ?? {}).filter((k) => !TYPE_ORDER.includes(k))].map((k) => ({ value: k, label: typeOf({ type: k }, custom).label })),\n },\n ],\n },\n canEditCols && { label: \"Insert left\", icon: \"arrow-left\", onSelect: () => insertColumn(c, \"left\") },\n canEditCols && { label: \"Insert right\", icon: \"arrow-right\", onSelect: () => insertColumn(c, \"right\") },\n canEditCols && { type: \"separator\" },\n c < frozenN\n ? { label: c === 0 && frozenN === 1 ? \"Unfreeze\" : \"Freeze up to here\", icon: \"pin\", onSelect: () => setView({ frozen: c === 0 && frozenN === 1 ? 0 : c + 1 }) }\n : { label: \"Freeze up to here\", icon: \"pin\", onSelect: () => setView({ frozen: c + 1 }) },\n cols.length > 1 && { label: \"Hide\", icon: \"eye-off\", onSelect: () => setView((v) => ({ hidden: [...v.hidden, col.key] })) },\n canEditCols && cols.length > 1 && { label: \"Delete field\", icon: \"trash\", tone: \"danger\", onSelect: () => deleteColumn(col) },\n ];\n }\n\n // ── the context menu (right-click, long press, Shift+F10) ─────────────\n const menuItems = (() => {\n if (!range || !viewRows.length) return [];\n const ids = viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id);\n const n = ids.length;\n const row = viewRows[range.r0];\n const rowAt = local.findIndex((x) => x.id === row?.id);\n return [\n {\n label: \"Copy\",\n icon: \"copy\",\n shortcut: \"⌘C\",\n onSelect: () => navigator.clipboard?.writeText(copyText()).then(() => toast.success(\"Copied\"), () => toast.error(\"Couldn’t reach the clipboard — use ⌘C\")),\n },\n !readOnly && { label: \"Clear\", icon: \"eraser\", shortcut: \"⌫\", onSelect: () => commitChanges(clearChanges(range, viewRows, cols, ctx, custom)) },\n !readOnly && range.r1 > range.r0 && { label: \"Fill down\", icon: \"arrow-down\", shortcut: \"⌘D\", onSelect: () => commitChanges(fillChanges({ ...range, r1: range.r0 }, range.r1, viewRows, cols)) },\n onExpandRow && n === 1 && { label: `Open ${noun}`, icon: \"maximize\", shortcut: \"Space\", onSelect: () => onExpandRow(row) },\n canAdd && { type: \"separator\" },\n canAdd && { label: `Insert ${noun} above`, icon: \"arrow-up\", onSelect: () => addRows([newRow()], { index: rowAt, hint: { ref: row.id, before: true } }) },\n canAdd && { label: `Insert ${noun} below`, icon: \"arrow-down\", onSelect: () => addRows([newRow()], { index: rowAt + 1, hint: { ref: row.id } }) },\n onDeleteRows && !readOnly && { type: \"separator\" },\n onDeleteRows && !readOnly && { label: n === 1 ? `Delete ${noun}` : `Delete ${plural(n, noun)}`, icon: \"trash\", tone: \"danger\", onSelect: () => deleteRows(ids) },\n ];\n })();\n\n // ── virtualisers ─────────────────────────────────────────────────────\n const activeRow = useRef(-1);\n activeRow.current = A ? A.r : -1;\n const activeCol = useRef(-1);\n activeCol.current = A ? A.c - frozenN : -1;\n const keepRow = useCallback((range) => {\n const out = defaultRangeExtractor(range);\n const a = activeRow.current;\n if (a >= 0 && a < range.count && !out.includes(a)) {\n out.push(a);\n out.sort((x, y) => x - y);\n }\n return out;\n }, []);\n const keepCol = useCallback((range) => {\n const out = defaultRangeExtractor(range);\n const a = activeCol.current;\n if (a >= 0 && a < range.count && !out.includes(a)) {\n out.push(a);\n out.sort((x, y) => x - y);\n }\n return out;\n }, []);\n\n const shownRows = loading ? 0 : viewRows.length;\n const rowV = useVirtualizer({\n count: shownRows,\n getScrollElement: () => scroller.current,\n estimateSize: () => rowH,\n overscan: 10,\n scrollMargin: headH,\n initialRect: { width: 1200, height: typeof height === \"number\" ? height : 560 },\n // The scroller's new size is read in the next frame, not inside the\n // observer's delivery (a synchronous re-render there is what the\n // browser reports as a ResizeObserver loop).\n useAnimationFrameWithResizeObserver: true,\n rangeExtractor: keepRow,\n getItemKey: (i) => viewRows[i]?.id ?? i,\n });\n const scrollCols = cols.slice(frozenN);\n const colV = useVirtualizer({\n horizontal: true,\n count: scrollCols.length,\n getScrollElement: () => scroller.current,\n estimateSize: (i) => scrollCols[i]?.width ?? 160,\n overscan: 2,\n scrollMargin: gutterW + frozenW,\n initialRect: { width: 1200, height: typeof height === \"number\" ? height : 560 },\n useAnimationFrameWithResizeObserver: true,\n rangeExtractor: keepCol,\n getItemKey: (i) => scrollCols[i]?.key ?? i,\n });\n const widthsSig = `${gutterW}|${frozenW}|${scrollCols.map((c) => c.width).join(\",\")}`;\n useLayoutEffect(() => {\n colV.measure();\n }, [widthsSig, colV]);\n useLayoutEffect(() => {\n rowV.measure();\n }, [rowH, headH, rowV]);\n\n const vRows = rowV.getVirtualItems();\n const vCols = colV.getVirtualItems();\n const bodyH = shownRows * rowH;\n const addLine = canAdd && !loading;\n const contentH = headH + (loading ? 8 * rowH : bodyH) + (addLine ? rowH : 0);\n\n // The imperative handle: for a parent that drives the grid (base-view).\n useImperativeHandle(\n ref,\n () => ({\n focus: focusGrid,\n select: ({ rowId, key, toRowId, toKey } = {}) => {\n const r = rowIndex.get(rowId);\n const c = key == null ? 0 : colIndex.get(key);\n if (r == null || c == null) return false;\n const r2 = toRowId != null ? rowIndex.get(toRowId) ?? r : r;\n const c2 = toKey != null ? colIndex.get(toKey) ?? c : c;\n select({ r, c }, { r: r2, c: c2 });\n ensureVisible(r, c);\n return true;\n },\n getSelection: () =>\n range\n ? {\n active: { rowId: viewRows[A.r]?.id, key: cols[A.c]?.key },\n rowIds: viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id),\n keys: cols.slice(range.c0, range.c1 + 1).map((c) => c.key),\n }\n : null,\n clearSelection: () => setSel(null),\n scrollToRow: (rowId) => {\n const r = rowIndex.get(rowId);\n if (r == null) return false;\n ensureVisible(r, null);\n return true;\n },\n edit: (rowId, key) => {\n const r = rowIndex.get(rowId);\n const c = colIndex.get(key);\n if (r == null || c == null) return false;\n startEdit(r, c);\n return true;\n },\n addRow: (values) => addRows([newRow(values)], { edit: true }),\n deleteRows: (ids) => deleteRows(ids),\n undo,\n redo,\n copy: copyText,\n paste: pasteText,\n visibleRows: () => viewRows,\n visibleColumns: () => cols,\n resort: () => setResortTick((n) => n + 1),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [rowIndex, colIndex, range, viewRows, cols, A],\n );\n\n // ── drawing ──────────────────────────────────────────────────────────\n 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;\n const activeId = A ? `${gid}-c-${A.r}-${A.c}` : undefined;\n\n function cellShadow(r, c) {\n if (!range || !inRange(range, r, c)) return undefined;\n const P = \"var(--color-primary)\";\n if (A && A.r === r && A.c === c) return `inset 0 0 0 2px ${P}`;\n const s = [];\n if (r === range.r0) s.push(`inset 0 1px 0 0 ${P}`);\n if (r === range.r1) s.push(`inset 0 -1px 0 0 ${P}`);\n if (c === range.c0) s.push(`inset 1px 0 0 0 ${P}`);\n if (c === range.c1) s.push(`inset -1px 0 0 0 ${P}`);\n return s.join(\", \") || undefined;\n }\n\n function renderCell(row, r, col, c, sticky) {\n const t = typeOf(col, custom);\n const isActive = A && A.r === r && A.c === c;\n const inSel = range && inRange(range, r, c);\n const isEditing = editing && editing.id === row.id && editing.key === col.key;\n const problem = problems.get(`${row.id}\\u0000${col.key}`);\n const Editor = isEditing ? editorFor(t) : null;\n const corner = range && !editing && !readOnly && r === range.r1 && c === range.c1 && lastPointer.current === \"mouse\";\n const filling = fillRange && inRange(fillRange, r, c) && !inRange(range, r, c);\n return (\n <div\n key={col.key}\n id={`${gid}-c-${r}-${c}`}\n role=\"gridcell\"\n aria-colindex={c + 2}\n aria-selected={inSel ? true : undefined}\n aria-readonly={!editable(c) || undefined}\n aria-invalid={problem ? true : undefined}\n title={problem || undefined}\n data-cell=\"\"\n data-r={r}\n data-c={c}\n className={cx(\n \"flex h-full shrink-0 items-center border-b border-r border-border bg-background px-2.5 text-sm text-foreground\",\n sticky ? \"sticky z-10\" : \"absolute top-0\",\n sticky && c === frozenN - 1 && \"border-r-input\",\n isActive && !sticky && \"z-[2]\",\n isEditing && \"z-20\",\n t.align === \"end\" && \"justify-end\",\n )}\n style={{ width: col.width, left: sticky ? colLeft[c] : colLeft[c] }}\n >\n <span\n aria-hidden=\"true\"\n className={cx(\n \"pointer-events-none absolute inset-0\",\n inSel ? (single ? \"\" : \"bg-primary/8\") : filling ? \"bg-primary/5\" : \"group-hover/row:bg-muted/60\",\n rowsMode && inSel && \"bg-primary/8\",\n )}\n style={{ boxShadow: cellShadow(r, c) }}\n />\n <span className={cx(\"relative flex h-full min-w-0 flex-1 items-center\", t.align === \"end\" && \"justify-end\")}>\n {Editor ? (\n <Editor\n value={row[col.key]}\n column={col}\n type={t}\n ctx={ctx}\n initial={editing.initial}\n today={today}\n canCreate={!readOnly}\n onCommit={commitEdit}\n onCancel={cancelEdit}\n />\n ) : (\n <CellValue\n value={row[col.key]}\n column={col}\n type={t}\n ctx={ctx}\n active={isActive}\n readOnly={!editable(c)}\n onSet={(v) => {\n commitChanges([{ rowId: row.id, key: col.key, value: v, prev: row[col.key] }]);\n focusGrid();\n }}\n />\n )}\n </span>\n {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}\n {corner ? (\n <span\n data-fill=\"\"\n aria-hidden=\"true\"\n title=\"Drag to fill\"\n className=\"absolute -bottom-[4px] -right-[4px] z-30 size-[9px] cursor-crosshair rounded-[2px] border-2 border-background bg-primary\"\n />\n ) : null}\n </div>\n );\n }\n\n function renderRow(vr) {\n const r = vr.index;\n const row = viewRows[r];\n if (!row) return null;\n const rowSelected = rowsMode && range && r >= range.r0 && r <= range.r1;\n const rowActive = A && A.r === r;\n return (\n <div\n key={row.id}\n role=\"row\"\n aria-rowindex={r + 2}\n aria-selected={rowSelected || undefined}\n className=\"group/row absolute left-0 top-0 flex\"\n style={{ height: rowH, width: totalW, transform: `translateY(${vr.start}px)` }}\n >\n <div\n role=\"rowheader\"\n data-gutter=\"\"\n data-r={r}\n className={cx(\n \"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\",\n rowSelected ? \"text-primary\" : rowActive ? \"text-foreground\" : \"text-muted-foreground\",\n )}\n style={{ width: gutterW }}\n >\n <span aria-hidden=\"true\" className={cx(\"pointer-events-none absolute inset-0\", rowSelected ? \"bg-primary/8\" : \"group-hover/row:bg-muted/60\")} />\n <span className=\"relative\">{r + 1}</span>\n {onExpandRow && !narrow ? (\n // raw: a 24px open-row glyph inside the 64px gutter, shown on the row's hover; IconButton's smallest square is 36px.\n <button\n type=\"button\"\n data-expand=\"\"\n tabIndex={-1}\n aria-label={`Open ${noun} ${r + 1}`}\n title={`Open ${noun}`}\n onClick={() => onExpandRow(row)}\n 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\"\n >\n <Icon name=\"maximize\" size={13} />\n </button>\n ) : null}\n </div>\n {cols.slice(0, frozenN).map((col, c) => renderCell(row, r, col, c, true))}\n {vCols.map((vc) => renderCell(row, r, scrollCols[vc.index], vc.index + frozenN, false))}\n </div>\n );\n }\n\n function renderHeadCell(col, c, sticky) {\n const t = typeOf(col, custom);\n const sortAt = view.sort.findIndex((s) => s.key === col.key);\n const sortDir = sortAt >= 0 ? view.sort[sortAt].dir : null;\n const isFiltered = view.filters.some((f) => f.key === col.key);\n const colSelected = range && range.r0 === 0 && range.r1 === viewRows.length - 1 && c >= range.c0 && c <= range.c1 && viewRows.length > 1;\n const dragging = colDrag && colDrag.from === c;\n return (\n <div\n key={col.key}\n role=\"columnheader\"\n aria-colindex={c + 2}\n aria-sort={sortDir === \"asc\" ? \"ascending\" : sortDir === \"desc\" ? \"descending\" : undefined}\n className={cx(\n \"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\",\n sticky ? \"sticky z-10\" : \"absolute top-0\",\n sticky && c === frozenN - 1 && \"border-r-input\",\n dragging && \"opacity-50\",\n )}\n style={{ width: col.width, left: colLeft[c] }}\n >\n {colSelected ? <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 bg-primary/10\" /> : null}\n <span className=\"relative flex min-w-0 flex-1 cursor-default items-center gap-1.5 self-stretch\" onPointerDown={(e) => onHeadPointerDown(e, c)}>\n <Icon name={t.icon} size={14} className=\"shrink-0 text-muted-foreground\" />\n {renaming === col.key ? (\n <RenameInput\n name={col.name}\n onDone={(name) => {\n setRenaming(null);\n focusGrid();\n if (name && name !== col.name) updateColumns(colsRef.current.map((x) => (x.key === col.key ? { ...x, name } : x)), \"rename\");\n }}\n />\n ) : (\n <span className=\"truncate\">{col.name}</span>\n )}\n {col.readOnly || col.computed ? <Icon name=\"lock\" size={12} className=\"shrink-0 text-muted-foreground\" label=\"Read only\" /> : null}\n {col.required ? (\n <span aria-label=\"Required\" className=\"text-destructive\">\n *\n </span>\n ) : null}\n </span>\n {sortDir ? (\n <span className=\"relative flex shrink-0 items-center text-primary\" title={`Sorted ${sortWords(t)[sortDir]}`}>\n <Icon name={sortDir === \"asc\" ? \"arrow-up\" : \"arrow-down\"} size={13} />\n {view.sort.length > 1 ? <span className=\"text-2xs tabular-nums\">{sortAt + 1}</span> : null}\n </span>\n ) : null}\n {isFiltered ? <span aria-label=\"Filtered\" title=\"Filtered\" className=\"relative size-1.5 shrink-0 rounded-full bg-primary\" /> : null}\n <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\")}>\n <Menu items={headerItems(col, c)} label={`${col.name}, field menu`} trigger={<Icon name=\"chevron-down\" size={14} />} align=\"start\" />\n </span>\n <span\n data-resize=\"\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={`Resize ${col.name}`}\n aria-valuenow={col.width}\n aria-valuemin={MIN_W}\n aria-valuemax={MAX_W}\n tabIndex={0}\n onPointerDown={(e) => onResizeDown(e, col)}\n onDoubleClick={() =>\n setView((v) => {\n const widths = { ...v.widths };\n delete widths[col.key];\n return { widths };\n })\n }\n onKeyDown={(e) => {\n if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n e.preventDefault();\n const w = clamp(col.width + (e.key === \"ArrowRight\" ? 16 : -16), MIN_W, MAX_W);\n setView((v) => ({ widths: { ...v.widths, [col.key]: w } }));\n }}\n 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\"\n />\n </div>\n );\n }\n\n const header = (\n <div role=\"row\" aria-rowindex={1} className=\"sticky top-0 z-30 flex\" style={{ height: headH, width: totalW }}>\n <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 }}>\n <span className=\"sr-only\">Row</span>\n <span aria-hidden=\"true\">#</span>\n </div>\n {cols.slice(0, frozenN).map((col, c) => renderHeadCell(col, c, true))}\n {vCols.map((vc) => renderHeadCell(scrollCols[vc.index], vc.index + frozenN, false))}\n {canEditCols ? (\n <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 }}>\n <Popover open={addOpen} onOpenChange={setAddOpen} trigger={<Icon name=\"plus\" size={16} />} variant=\"quiet\" size=\"sm\" width=\"auto\" align=\"end\" label=\"Add a field\">\n <AddFieldPanel\n names={localCols.map((x) => x.name)}\n custom={custom}\n onAdd={(f) => {\n const col = { key: uid(\"field\"), ...f };\n addColumn(col);\n setAddOpen(false);\n // Show the new field: it is the last one.\n requestAnimationFrame(() => {\n if (scroller.current) scroller.current.scrollLeft = scroller.current.scrollWidth;\n });\n }}\n />\n </Popover>\n </div>\n ) : null}\n {colDrag ? (\n <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 }} />\n ) : null}\n </div>\n );\n\n // ── the body when there is nothing to show ───────────────────────────\n let emptyBody = null;\n if (!loading && viewRows.length === 0) {\n const blank = local.length === 0;\n emptyBody =\n blank && empty ? (\n empty\n ) : (\n <div className=\"grid max-w-sm justify-items-center gap-2 text-center\">\n <span className=\"grid size-10 place-items-center rounded-full bg-muted text-muted-foreground\">\n <Icon name={blank ? \"grid\" : \"filter\"} size={18} />\n </span>\n <p className=\"text-sm font-medium\">{blank ? (cols.length ? `No ${noun}s yet` : \"No fields yet\") : `No ${noun}s match`}</p>\n <p className=\"text-sm text-muted-foreground\">\n {blank\n ? cols.length\n ? canAdd\n ? `Add a ${noun}, or click here and paste rows copied from a spreadsheet.`\n : `When there are ${noun}s, they show here.`\n : \"Add a field to start the table.\"\n : \"Try another search, or clear the filters to see everything.\"}\n </p>\n <div className=\"mt-2\">\n {blank ? (\n canAdd && cols.length ? (\n <Button variant=\"primary\" icon=\"plus\" onClick={() => addRows([newRow()], { edit: true })}>\n Add a {noun}\n </Button>\n ) : null\n ) : (\n <Button\n onClick={() => {\n setSearch(\"\");\n setView({ filters: [] });\n }}\n >\n Clear filters\n </Button>\n )}\n </div>\n </div>\n );\n }\n\n // ── status line ──────────────────────────────────────────────────────\n const summary = range && !single ? summarize(range, viewRows, cols) : null;\n const activeProblem = A ? problems.get(`${viewRows[A.r]?.id}\\u0000${cols[A.c]?.key}`) : null;\n function nextProblem() {\n for (let r = 0; r < viewRows.length; r++) {\n for (let c = 0; c < cols.length; c++) {\n if (problems.has(`${viewRows[r].id}\\u0000${cols[c].key}`) && (!A || r > A.r || (r === A.r && c > A.c))) {\n select({ r, c });\n ensureVisible(r, c);\n focusGrid();\n return;\n }\n }\n }\n // wrap to the first\n for (let r = 0; r < viewRows.length; r++)\n for (let c = 0; c < cols.length; c++)\n if (problems.has(`${viewRows[r].id}\\u0000${cols[c].key}`)) {\n select({ r, c });\n ensureVisible(r, c);\n focusGrid();\n return;\n }\n }\n const fmtSum = (n) => (summary?.numCol ? typeOf(summary.numCol, custom).format(n, summary.numCol, ctx) : n.toLocaleString(locale, { maximumFractionDigits: 2 }));\n\n const btnSize = \"md\";\n const hiddenCount = view.hidden.filter((k) => localCols.some((c) => c.key === k)).length;\n const filterCount = view.filters.length;\n\n return (\n <div ref={rootRef} className={cx(\"grid min-w-0 gap-3\", className)}>\n {toolbar ? (\n <div className=\"flex flex-wrap items-center gap-2\">\n {toolbarStart}\n <div className=\"min-w-0 flex-1 max-sm:basis-full sm:max-w-72\">\n <InputGroup\n icon=\"search\"\n placeholder={`Search ${noun}s`}\n aria-label={`Search ${noun}s`}\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n action={search ? { label: \"Clear search\", icon: \"x\", onClick: () => setSearch(\"\") } : undefined}\n />\n </div>\n <Popover\n open={filterOpen}\n onOpenChange={setFilterOpen}\n variant={filterCount ? \"secondary\" : \"quiet\"}\n size={btnSize}\n width=\"auto\"\n align=\"start\"\n label=\"Filter\"\n trigger={\n <>\n <Icon name=\"filter\" />\n Filter\n {filterCount ? (\n <Badge variant=\"soft\" tone=\"primary\" className=\"tabular-nums\">\n {filterCount}\n </Badge>\n ) : null}\n </>\n }\n >\n <FilterPanel columns={orderedColumns(localCols, view)} filters={view.filters} ctx={ctx} custom={custom} onChange={(filters) => setView({ filters })} />\n </Popover>\n <Popover\n variant={view.sort.length ? \"secondary\" : \"quiet\"}\n size={btnSize}\n width=\"auto\"\n align=\"start\"\n label=\"Sort\"\n trigger={\n <>\n <Icon name=\"chevrons-up-down\" />\n Sort\n {view.sort.length ? (\n <Badge variant=\"soft\" tone=\"primary\" className=\"tabular-nums\">\n {view.sort.length}\n </Badge>\n ) : null}\n </>\n }\n >\n <SortPanel columns={orderedColumns(localCols, view)} sort={view.sort} custom={custom} onChange={(sort) => setView({ sort })} />\n </Popover>\n <Popover\n variant={hiddenCount ? \"secondary\" : \"quiet\"}\n size={btnSize}\n width=\"auto\"\n align=\"start\"\n label=\"Fields\"\n trigger={\n <>\n <Icon name={hiddenCount ? \"eye-off\" : \"eye\"} />\n {hiddenCount ? `${hiddenCount} hidden` : \"Fields\"}\n </>\n }\n >\n <FieldsPanel columns={orderedColumns(localCols, view)} hidden={view.hidden} custom={custom} onChange={(hidden) => setView({ hidden })} />\n </Popover>\n <div className=\"ml-auto flex items-center gap-1\">\n {!readOnly ? (\n <>\n <IconButton icon=\"undo\" label=\"Undo\" disabled={!undoStack.current.length} onClick={undo} className=\"max-sm:hidden\" />\n <IconButton icon=\"redo\" label=\"Redo\" disabled={!redoStack.current.length} onClick={redo} className=\"max-sm:hidden\" />\n </>\n ) : null}\n {toolbarEnd}\n {canAdd ? (\n narrow ? (\n <IconButton icon=\"plus\" variant=\"primary\" label={`Add a ${noun}`} onClick={() => addRows([newRow()], { edit: true })} />\n ) : (\n <Button variant=\"primary\" icon=\"plus\" onClick={() => addRows([newRow()], { edit: true })}>\n Add {noun}\n </Button>\n )\n ) : null}\n </div>\n </div>\n ) : null}\n\n <div className=\"relative min-w-0 overflow-hidden rounded-lg border border-border bg-background\" style={{ height }}>\n <ContextMenu items={menuItems} label=\"Cell actions\" className=\"h-full\">\n <div\n ref={scroller}\n role=\"grid\"\n aria-label={label}\n aria-rowcount={viewRows.length + 1}\n aria-colcount={cols.length + 1}\n aria-multiselectable=\"true\"\n aria-activedescendant={activeId}\n aria-busy={loading || undefined}\n tabIndex={0}\n className={cx(\"h-full overflow-auto overscroll-contain outline-none\", !A && \"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\")}\n onKeyDown={onKeyDown}\n onPointerDown={onPointerDown}\n onClick={onClick}\n onDoubleClick={onDoubleClick}\n onCopy={(e) => {\n if (editingRef.current || e.target !== scroller.current || !range) return;\n e.preventDefault();\n e.clipboardData.setData(\"text/plain\", copyText());\n }}\n onCut={(e) => {\n if (editingRef.current || e.target !== scroller.current || !range) return;\n e.preventDefault();\n e.clipboardData.setData(\"text/plain\", copyText());\n if (!readOnly) commitChanges(clearChanges(range, viewRows, cols, ctx, custom));\n }}\n onPaste={(e) => {\n if (editingRef.current || e.target !== scroller.current) return;\n e.preventDefault();\n pasteText(e.clipboardData.getData(\"text/plain\"));\n }}\n >\n <div className=\"relative select-none\" style={{ width: totalW, height: emptyBody ? undefined : contentH, minHeight: \"100%\" }}>\n {header}\n {loading\n ? Array.from({ length: 8 }, (_, i) => (\n <div key={i} className=\"absolute left-0 flex\" style={{ top: headH + i * rowH, height: rowH, width: totalW }}>\n <div className=\"sticky left-0 z-10 shrink-0 border-b border-r border-border bg-background\" style={{ width: gutterW }} />\n {cols.map((col) => (\n <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 }}>\n <Skeleton className={cx(\"h-3\", i % 3 === 0 ? \"w-3/5\" : i % 3 === 1 ? \"w-4/5\" : \"w-2/5\")} />\n </div>\n ))}\n </div>\n ))\n : vRows.map(renderRow)}\n {addLine && !emptyBody ? (\n <div className=\"absolute left-0 flex\" style={{ top: headH + bodyH, height: rowH, width: totalW }}>\n {/* 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. */}\n <button\n type=\"button\"\n tabIndex={-1}\n onClick={() => addRows([newRow()], { edit: true })}\n 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\"\n style={{ width: gutterW + frozenW }}\n >\n <Icon name=\"plus\" size={14} />\n Add {noun}\n </button>\n </div>\n ) : null}\n {emptyBody ? (\n <div className=\"sticky left-0 grid place-items-center px-4 py-10\" style={{ width: boxW ? boxW - 2 : \"100%\" }}>\n {emptyBody}\n </div>\n ) : null}\n </div>\n </div>\n </ContextMenu>\n </div>\n\n <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\">\n <div className=\"flex items-center gap-3\">\n <span className=\"tabular-nums\">\n {loading ? \"Loading…\" : filtered ? `${viewRows.length.toLocaleString()} of ${plural(local.length, noun)}` : plural(local.length, noun)}\n </span>\n {order.current.dirty && (view.sort.length || view.filters.length) ? (\n <Button variant=\"link\" size=\"inherit\" onClick={() => setResortTick((n) => n + 1)}>\n {view.sort.length ? \"Sort again\" : \"Filter again\"}\n </Button>\n ) : null}\n {problems.size ? (\n <Button variant=\"link\" size=\"inherit\" tone=\"muted\" onClick={nextProblem}>\n <span aria-hidden=\"true\" className=\"mr-1.5 inline-block size-1.5 rounded-full bg-destructive align-middle\" />\n {problems.size === 1 ? \"1 problem\" : `${problems.size.toLocaleString()} problems`}\n </Button>\n ) : null}\n </div>\n <div className=\"flex items-center gap-3\">\n {activeProblem ? <span className=\"text-destructive\">{activeProblem}</span> : null}\n {rowsMode ? (\n <>\n <span className=\"tabular-nums\">{plural(range.r1 - range.r0 + 1, noun)} selected</span>\n {onDeleteRows && !readOnly ? (\n <Button variant=\"link\" size=\"inherit\" tone=\"muted\" onClick={() => deleteRows(viewRows.slice(range.r0, range.r1 + 1).map((r) => r.id))}>\n Delete\n </Button>\n ) : null}\n </>\n ) : summary ? (\n <span className=\"tabular-nums\">\n {summary.nums > 1 ? `Sum ${fmtSum(summary.sum)} · Average ${fmtSum(summary.avg)} · ` : \"\"}\n {summary.filled.toLocaleString()} of {summary.count.toLocaleString()} filled\n </span>\n ) : null}\n {!narrow && !coarse ? (\n <Popover trigger={<Icon name=\"keyboard\" size={16} />} variant=\"quiet\" size=\"sm\" width=\"auto\" align=\"end\" side=\"top\" label=\"Keyboard shortcuts\">\n <ShortcutsPanel />\n </Popover>\n ) : null}\n </div>\n </div>\n </div>\n );\n}\n\nfunction restoreRows(rs, removed) {\n const out = rs.slice();\n for (const { row, index } of [...removed].sort((a, b) => a.index - b.index)) {\n if (out.some((r) => r.id === row.id)) continue;\n out.splice(Math.min(index, out.length), 0, row);\n }\n return out;\n}\n\nfunction RenameInput({ name, onDone }) {\n const [text, setText] = useState(name);\n const input = useRef(null);\n const done = useRef(false);\n useLayoutEffect(() => {\n input.current?.focus();\n input.current?.select();\n }, []);\n const finish = (v) => {\n if (done.current) return;\n done.current = true;\n onDone(v);\n };\n return (\n // raw: a header-sized rename box inside a 40px column header; the kit's Input is a 44px field.\n <input\n ref={input}\n value={text}\n aria-label=\"Field name\"\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => {\n e.stopPropagation();\n if (e.key === \"Enter\") finish(text.trim());\n else if (e.key === \"Escape\") finish(null);\n }}\n onBlur={() => finish(text.trim())}\n 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\"\n />\n );\n}\n"
25
25
  },
26
26
  {
27
27
  "path": "registry/blocks/spreadsheet/cells.jsx",
@@ -83,7 +83,7 @@
83
83
  "docs": "Installed to components/makersclaw/blocks/spreadsheet. Import: import { Spreadsheet, CellValue, CellPanel, OptionBadge, editorFor, TYPE_ORDER, sortWords, COLUMN_TYPES, typeOf, parseCell, convertValue, guessType, parseNumberText, parseDateText, optionValue, isBlank, emptyView, normalizeView, orderedColumns, visibleColumns, processRows, passesFilters, parseTSV, toTSV, pasteChanges, fillChanges, clearChanges, applyChanges, invertChanges } from \"@/components/makersclaw/blocks/spreadsheet\". Props and an example: components/makersclaw/blocks/spreadsheet/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
84
84
  "meta": {
85
85
  "makersclaw": {
86
- "version": "0.8.0",
86
+ "version": "0.8.2",
87
87
  "useWhen": "People need to enter or clean up many records in a grid, fast, with the keyboard: a lead list, an inventory, a budget, a content calendar, a table view over any collection, the grid under an Airtable-like base. Not for a read-mostly list to scan and act on row by row (data-table), records moving through stages (board), or a single record (record-form)."
88
88
  }
89
89
  }
package/r/stat-cards.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "Stat cards",
6
6
  "description": "A row of key numbers, each with its name and its change: one bordered strip, or a card each with a change in the corner or after the value, a \"from\" value, a status dot and a goal meter, a sparkline, a \"View more\" footer; or a usage tile per amount against its limit (a bar or a ring, a split by kind with what is free, a warning line near the cap, an action and a limit the founder can change); or a plain list of values with their change. Loading, empty and error states.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.8.0/r/chart.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.2/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.8.2/r/chart.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -59,7 +59,7 @@
59
59
  "docs": "Installed to components/makersclaw/blocks/stat-cards. Import: import { StatCards } from \"@/components/makersclaw/blocks/stat-cards\". Props and an example: components/makersclaw/blocks/stat-cards/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
60
60
  "meta": {
61
61
  "makersclaw": {
62
- "version": "0.8.0",
62
+ "version": "0.8.2",
63
63
  "useWhen": "The few numbers at the top of a dashboard or a report: revenue, users, conversion, spend against budget, seats and storage against a plan. Not for a trend worth reading on its own (a chart), many rows of records (a table), or one subject scored against thresholds (a scorecard)."
64
64
  }
65
65
  }