makersclaw 0.7.0 → 0.8.1
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.
- package/README.md +17 -4
- package/package.json +1 -1
- package/r/activity-log.json +52 -0
- package/r/agent-changes.json +51 -0
- package/r/agent-chat.json +53 -0
- package/r/agent-column.json +85 -0
- package/r/agent-dock.json +57 -0
- package/r/agent-kit.json +49 -0
- package/r/agent-marks.json +49 -0
- package/r/agent-pane.json +93 -0
- package/r/ai-action.json +56 -0
- package/r/annotate.json +4 -4
- package/r/approval-request.json +2 -2
- package/r/approval.json +54 -0
- package/r/ask-bar.json +50 -0
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/automation-editor.json +66 -0
- package/r/automation-list.json +57 -0
- package/r/automation-model.json +118 -0
- package/r/automations-screen.json +71 -0
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +5 -5
- package/r/chat-pane.json +84 -0
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/connect-card.json +51 -0
- package/r/contact-section.json +2 -2
- package/r/core.json +18 -6
- package/r/cost.json +51 -0
- package/r/csv-import.json +3 -3
- package/r/data-picker.json +60 -0
- package/r/data-table.json +2 -2
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/first-run.json +63 -0
- package/r/fix-note.json +57 -0
- package/r/flow-canvas.json +3 -3
- package/r/flow-editor.json +79 -0
- package/r/footer.json +3 -3
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/library-shelf.json +56 -0
- package/r/map.json +2 -2
- package/r/margin-notes.json +67 -0
- package/r/menubar.json +2 -2
- package/r/mind-map.json +4 -4
- package/r/navigation-menu.json +2 -2
- package/r/needs-you.json +55 -0
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/pace-bar.json +69 -0
- package/r/page-builder.json +3 -3
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-grid.json +52 -0
- package/r/record-notes.json +4 -4
- package/r/registry.json +4089 -1510
- package/r/run-history.json +52 -0
- package/r/run-view.json +80 -0
- package/r/schedule-card.json +54 -0
- package/r/schedule-engine.json +79 -0
- package/r/schedule-gantt.json +83 -0
- package/r/schedule-ruler.json +64 -0
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +3 -3
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +3 -3
- package/r/source-receipt.json +52 -0
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/step-picker.json +53 -0
- package/r/step-settings.json +72 -0
- package/r/suggestion.json +58 -0
- package/r/sum-bar.json +64 -0
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/tier-map.json +67 -0
- package/r/time-grid.json +73 -0
- package/r/time-strip.json +64 -0
- package/r/top-bar.json +66 -0
- package/r/transaction-list.json +2 -2
- package/r/trigger-picker.json +61 -0
- package/r/whiteboard.json +4 -4
- package/r/workflow-view.json +55 -0
- package/registry/blocks/activity-log/README.md +112 -0
- package/registry/blocks/activity-log/activity-log.jsx +351 -0
- package/registry/blocks/activity-log/block.json +33 -0
- package/registry/blocks/activity-log/demo.jsx +48 -0
- package/registry/blocks/activity-log/index.js +4 -0
- package/registry/blocks/agent-changes/README.md +94 -0
- package/registry/blocks/agent-changes/agent-changes.jsx +118 -0
- package/registry/blocks/agent-changes/block.json +28 -0
- package/registry/blocks/agent-changes/demo.jsx +39 -0
- package/registry/blocks/agent-changes/index.js +3 -0
- package/registry/blocks/agent-chat/README.md +145 -0
- package/registry/blocks/agent-chat/agent-chat.jsx +415 -0
- package/registry/blocks/agent-chat/block.json +30 -0
- package/registry/blocks/agent-chat/demo.jsx +165 -0
- package/registry/blocks/agent-chat/index.js +3 -0
- package/registry/blocks/agent-column/README.md +197 -0
- package/registry/blocks/agent-column/agent-column.jsx +492 -0
- package/registry/blocks/agent-column/block.json +67 -0
- package/registry/blocks/agent-column/column-picker.jsx +163 -0
- package/registry/blocks/agent-column/demo.jsx +50 -0
- package/registry/blocks/agent-column/index.js +7 -0
- package/registry/blocks/agent-column/model.js +164 -0
- package/registry/blocks/agent-column/receipt.jsx +483 -0
- package/registry/blocks/agent-column/spreadsheet-type.jsx +24 -0
- package/registry/blocks/agent-dock/README.md +210 -0
- package/registry/blocks/agent-dock/agent-dock.jsx +356 -0
- package/registry/blocks/agent-dock/block.json +47 -0
- package/registry/blocks/agent-dock/demo.jsx +153 -0
- package/registry/blocks/agent-dock/dock-model.js +118 -0
- package/registry/blocks/agent-dock/index.js +5 -0
- package/registry/blocks/agent-kit/README.md +73 -0
- package/registry/blocks/agent-kit/agent-kit.jsx +86 -0
- package/registry/blocks/agent-kit/block.json +10 -0
- package/registry/blocks/agent-kit/demo.jsx +35 -0
- package/registry/blocks/agent-kit/index.js +3 -0
- package/registry/blocks/agent-marks/README.md +80 -0
- package/registry/blocks/agent-marks/agent-marks.jsx +133 -0
- package/registry/blocks/agent-marks/block.json +27 -0
- package/registry/blocks/agent-marks/demo.jsx +49 -0
- package/registry/blocks/agent-marks/index.js +3 -0
- package/registry/blocks/agent-pane/README.md +225 -0
- package/registry/blocks/agent-pane/agent-pane.jsx +244 -0
- package/registry/blocks/agent-pane/block.json +41 -0
- package/registry/blocks/agent-pane/composer.jsx +6 -0
- package/registry/blocks/agent-pane/demo.jsx +233 -0
- package/registry/blocks/agent-pane/dock-context.js +10 -0
- package/registry/blocks/agent-pane/index.js +4 -0
- package/registry/blocks/agent-pane/prose.jsx +84 -0
- package/registry/blocks/agent-pane/questions.jsx +134 -0
- package/registry/blocks/agent-pane/transcript.jsx +268 -0
- package/registry/blocks/agent-pane/work.jsx +368 -0
- package/registry/blocks/ai-action/README.md +94 -0
- package/registry/blocks/ai-action/ai-action.jsx +188 -0
- package/registry/blocks/ai-action/block.json +34 -0
- package/registry/blocks/ai-action/demo.jsx +93 -0
- package/registry/blocks/ai-action/index.js +3 -0
- package/registry/blocks/annotate/README.md +14 -11
- package/registry/blocks/approval/README.md +145 -0
- package/registry/blocks/approval/approval.jsx +163 -0
- package/registry/blocks/approval/block.json +31 -0
- package/registry/blocks/approval/demo.jsx +121 -0
- package/registry/blocks/approval/index.js +3 -0
- package/registry/blocks/ask-bar/README.md +105 -0
- package/registry/blocks/ask-bar/ask-bar.jsx +166 -0
- package/registry/blocks/ask-bar/block.json +26 -0
- package/registry/blocks/ask-bar/demo.jsx +78 -0
- package/registry/blocks/ask-bar/index.js +3 -0
- package/registry/blocks/automation-editor/README.md +291 -0
- package/registry/blocks/automation-editor/automation-editor.jsx +656 -0
- package/registry/blocks/automation-editor/block.json +39 -0
- package/registry/blocks/automation-editor/demo.jsx +284 -0
- package/registry/blocks/automation-editor/index.js +4 -0
- package/registry/blocks/automation-editor/run-inputs.jsx +82 -0
- package/registry/blocks/automation-list/README.md +129 -0
- package/registry/blocks/automation-list/automation-list.jsx +476 -0
- package/registry/blocks/automation-list/block.json +35 -0
- package/registry/blocks/automation-list/demo.jsx +67 -0
- package/registry/blocks/automation-list/index.js +3 -0
- package/registry/blocks/automation-model/README.md +109 -0
- package/registry/blocks/automation-model/block.json +135 -0
- package/registry/blocks/automation-model/catalog.js +256 -0
- package/registry/blocks/automation-model/check.js +161 -0
- package/registry/blocks/automation-model/demo.jsx +42 -0
- package/registry/blocks/automation-model/edit.js +262 -0
- package/registry/blocks/automation-model/fixes.js +287 -0
- package/registry/blocks/automation-model/glyph.jsx +13 -0
- package/registry/blocks/automation-model/graph.js +410 -0
- package/registry/blocks/automation-model/index.js +33 -0
- package/registry/blocks/automation-model/overlay.js +175 -0
- package/registry/blocks/automation-model/parts.jsx +94 -0
- package/registry/blocks/automation-model/schedule.js +53 -0
- package/registry/blocks/automation-model/templates.js +128 -0
- package/registry/blocks/automation-model/values.js +29 -0
- package/registry/blocks/automation-model/words.js +439 -0
- package/registry/blocks/automations-screen/README.md +207 -0
- package/registry/blocks/automations-screen/automations-screen.jsx +452 -0
- package/registry/blocks/automations-screen/block.json +47 -0
- package/registry/blocks/automations-screen/demo.jsx +364 -0
- package/registry/blocks/automations-screen/index.js +4 -0
- package/registry/blocks/automations-screen/screen-model.js +129 -0
- package/registry/blocks/chart/cartesian.jsx +1 -1
- package/registry/blocks/chart/core.jsx +33 -23
- package/registry/blocks/chart/pie.jsx +1 -1
- package/registry/blocks/chat/README.md +4 -1
- package/registry/blocks/chat/demo.jsx +2 -2
- package/registry/blocks/chat-pane/README.md +143 -0
- package/registry/blocks/chat-pane/block.json +48 -0
- package/registry/blocks/chat-pane/chat-attach.js +166 -0
- package/registry/blocks/chat-pane/chat-composer.jsx +195 -0
- package/registry/blocks/chat-pane/chat-context.js +17 -0
- package/registry/blocks/chat-pane/chat-model.js +43 -0
- package/registry/blocks/chat-pane/chat-pane.jsx +398 -0
- package/registry/blocks/chat-pane/chats-list.jsx +52 -0
- package/registry/blocks/chat-pane/demo.jsx +103 -0
- package/registry/blocks/chat-pane/index.js +9 -0
- package/registry/blocks/connect-card/README.md +99 -0
- package/registry/blocks/connect-card/block.json +10 -0
- package/registry/blocks/connect-card/connect-card.jsx +103 -0
- package/registry/blocks/connect-card/demo.jsx +49 -0
- package/registry/blocks/connect-card/index.js +3 -0
- package/registry/blocks/cost/README.md +115 -0
- package/registry/blocks/cost/block.json +10 -0
- package/registry/blocks/cost/cost.jsx +176 -0
- package/registry/blocks/cost/demo.jsx +66 -0
- package/registry/blocks/cost/index.js +3 -0
- package/registry/blocks/data-picker/README.md +81 -0
- package/registry/blocks/data-picker/block.json +38 -0
- package/registry/blocks/data-picker/data-picker.jsx +210 -0
- package/registry/blocks/data-picker/demo.jsx +34 -0
- package/registry/blocks/data-picker/fields.js +89 -0
- package/registry/blocks/data-picker/index.js +4 -0
- package/registry/blocks/data-table/demo.jsx +1 -1
- package/registry/blocks/first-run/README.md +118 -0
- package/registry/blocks/first-run/block.json +11 -0
- package/registry/blocks/first-run/demo.jsx +88 -0
- package/registry/blocks/first-run/first-run-model.js +51 -0
- package/registry/blocks/first-run/first-run.jsx +156 -0
- package/registry/blocks/first-run/index.js +5 -0
- package/registry/blocks/first-run/probe-rules.js +26 -0
- package/registry/blocks/fix-note/README.md +79 -0
- package/registry/blocks/fix-note/block.json +35 -0
- package/registry/blocks/fix-note/demo.jsx +30 -0
- package/registry/blocks/fix-note/fix-note.jsx +235 -0
- package/registry/blocks/fix-note/index.js +3 -0
- package/registry/blocks/flow-canvas/README.md +15 -8
- package/registry/blocks/flow-editor/README.md +108 -0
- package/registry/blocks/flow-editor/block.json +44 -0
- package/registry/blocks/flow-editor/demo.jsx +115 -0
- package/registry/blocks/flow-editor/flow-canvas.jsx +568 -0
- package/registry/blocks/flow-editor/flow-editor.jsx +154 -0
- package/registry/blocks/flow-editor/index.js +6 -0
- package/registry/blocks/flow-editor/minimap.jsx +78 -0
- package/registry/blocks/flow-editor/outline.jsx +134 -0
- package/registry/blocks/footer/README.md +3 -2
- package/registry/blocks/library-shelf/README.md +117 -0
- package/registry/blocks/library-shelf/block.json +28 -0
- package/registry/blocks/library-shelf/demo.jsx +68 -0
- package/registry/blocks/library-shelf/index.js +3 -0
- package/registry/blocks/library-shelf/library-shelf.jsx +211 -0
- package/registry/blocks/library-shelf/probe-rules.js +15 -0
- package/registry/blocks/margin-notes/README.md +222 -0
- package/registry/blocks/margin-notes/block.json +10 -0
- package/registry/blocks/margin-notes/demo.jsx +144 -0
- package/registry/blocks/margin-notes/index.js +20 -0
- package/registry/blocks/margin-notes/margin-model.js +224 -0
- package/registry/blocks/margin-notes/margin-notes.jsx +465 -0
- package/registry/blocks/mind-map/README.md +4 -2
- package/registry/blocks/needs-you/README.md +177 -0
- package/registry/blocks/needs-you/block.json +34 -0
- package/registry/blocks/needs-you/demo.jsx +143 -0
- package/registry/blocks/needs-you/index.js +3 -0
- package/registry/blocks/needs-you/needs-you.jsx +441 -0
- package/registry/blocks/org-chart/README.md +4 -2
- package/registry/blocks/pace-bar/README.md +230 -0
- package/registry/blocks/pace-bar/block.json +10 -0
- package/registry/blocks/pace-bar/demo.jsx +255 -0
- package/registry/blocks/pace-bar/index.js +22 -0
- package/registry/blocks/pace-bar/pace-bar.jsx +461 -0
- package/registry/blocks/pace-bar/pace-model.js +288 -0
- package/registry/blocks/page-builder/README.md +1 -1
- package/registry/blocks/page-builder/demo.jsx +3 -3
- package/registry/blocks/pdf-viewer/README.md +4 -1
- package/registry/blocks/record-grid/README.md +100 -0
- package/registry/blocks/record-grid/block.json +28 -0
- package/registry/blocks/record-grid/demo.jsx +43 -0
- package/registry/blocks/record-grid/index.js +3 -0
- package/registry/blocks/record-grid/record-grid.jsx +251 -0
- package/registry/blocks/run-history/README.md +138 -0
- package/registry/blocks/run-history/block.json +10 -0
- package/registry/blocks/run-history/demo.jsx +74 -0
- package/registry/blocks/run-history/index.js +3 -0
- package/registry/blocks/run-history/run-history.jsx +218 -0
- package/registry/blocks/run-view/README.md +174 -0
- package/registry/blocks/run-view/block.json +50 -0
- package/registry/blocks/run-view/demo.jsx +286 -0
- package/registry/blocks/run-view/index.js +5 -0
- package/registry/blocks/run-view/run-controls.jsx +266 -0
- package/registry/blocks/run-view/run-view.jsx +236 -0
- package/registry/blocks/run-view/runs-list.jsx +99 -0
- package/registry/blocks/schedule-card/README.md +121 -0
- package/registry/blocks/schedule-card/block.json +10 -0
- package/registry/blocks/schedule-card/demo.jsx +82 -0
- package/registry/blocks/schedule-card/index.js +3 -0
- package/registry/blocks/schedule-card/schedule-card.jsx +136 -0
- package/registry/blocks/schedule-engine/README.md +137 -0
- package/registry/blocks/schedule-engine/block.json +10 -0
- package/registry/blocks/schedule-engine/demo.jsx +82 -0
- package/registry/blocks/schedule-engine/engine.js +769 -0
- package/registry/blocks/schedule-engine/index.js +9 -0
- package/registry/blocks/schedule-engine/ops.js +71 -0
- package/registry/blocks/schedule-engine/place.js +294 -0
- package/registry/blocks/schedule-engine/tree.js +79 -0
- package/registry/blocks/schedule-engine/waits.js +80 -0
- package/registry/blocks/schedule-gantt/README.md +159 -0
- package/registry/blocks/schedule-gantt/block.json +10 -0
- package/registry/blocks/schedule-gantt/demo.jsx +223 -0
- package/registry/blocks/schedule-gantt/gantt-model.js +219 -0
- package/registry/blocks/schedule-gantt/geometry.js +176 -0
- package/registry/blocks/schedule-gantt/index.js +26 -0
- package/registry/blocks/schedule-gantt/schedule-gantt.jsx +882 -0
- package/registry/blocks/schedule-gantt/sheet.jsx +372 -0
- package/registry/blocks/schedule-gantt/track.jsx +190 -0
- package/registry/blocks/schedule-ruler/README.md +148 -0
- package/registry/blocks/schedule-ruler/block.json +10 -0
- package/registry/blocks/schedule-ruler/demo.jsx +181 -0
- package/registry/blocks/schedule-ruler/index.js +21 -0
- package/registry/blocks/schedule-ruler/ruler-model.js +239 -0
- package/registry/blocks/schedule-ruler/schedule-ruler.jsx +548 -0
- package/registry/blocks/sidebar/README.md +23 -13
- package/registry/blocks/sketch-canvas/README.md +15 -9
- package/registry/blocks/source-receipt/README.md +97 -0
- package/registry/blocks/source-receipt/block.json +30 -0
- package/registry/blocks/source-receipt/demo.jsx +71 -0
- package/registry/blocks/source-receipt/index.js +3 -0
- package/registry/blocks/source-receipt/source-receipt.jsx +107 -0
- package/registry/blocks/spreadsheet/spreadsheet.jsx +15 -1
- package/registry/blocks/step-picker/README.md +78 -0
- package/registry/blocks/step-picker/block.json +31 -0
- package/registry/blocks/step-picker/demo.jsx +23 -0
- package/registry/blocks/step-picker/index.js +3 -0
- package/registry/blocks/step-picker/step-picker.jsx +144 -0
- package/registry/blocks/step-settings/README.md +100 -0
- package/registry/blocks/step-settings/block.json +44 -0
- package/registry/blocks/step-settings/demo.jsx +93 -0
- package/registry/blocks/step-settings/fields.jsx +58 -0
- package/registry/blocks/step-settings/flow-settings.jsx +115 -0
- package/registry/blocks/step-settings/index.js +5 -0
- package/registry/blocks/step-settings/step-settings.jsx +290 -0
- package/registry/blocks/suggestion/README.md +171 -0
- package/registry/blocks/suggestion/block.json +40 -0
- package/registry/blocks/suggestion/demo.jsx +91 -0
- package/registry/blocks/suggestion/index.js +3 -0
- package/registry/blocks/suggestion/suggestion.jsx +436 -0
- package/registry/blocks/sum-bar/README.md +122 -0
- package/registry/blocks/sum-bar/block.json +10 -0
- package/registry/blocks/sum-bar/demo.jsx +110 -0
- package/registry/blocks/sum-bar/index.js +5 -0
- package/registry/blocks/sum-bar/sum-bar.jsx +209 -0
- package/registry/blocks/sum-bar/sum-model.js +126 -0
- package/registry/blocks/tier-map/README.md +217 -0
- package/registry/blocks/tier-map/block.json +10 -0
- package/registry/blocks/tier-map/demo.jsx +222 -0
- package/registry/blocks/tier-map/index.js +24 -0
- package/registry/blocks/tier-map/tier-map.jsx +574 -0
- package/registry/blocks/tier-map/tier-model.js +318 -0
- package/registry/blocks/time-grid/README.md +305 -0
- package/registry/blocks/time-grid/block.json +10 -0
- package/registry/blocks/time-grid/demo.jsx +279 -0
- package/registry/blocks/time-grid/grid-model.js +322 -0
- package/registry/blocks/time-grid/index.js +25 -0
- package/registry/blocks/time-grid/time-grid.jsx +1325 -0
- package/registry/blocks/time-strip/README.md +141 -0
- package/registry/blocks/time-strip/block.json +10 -0
- package/registry/blocks/time-strip/demo.jsx +129 -0
- package/registry/blocks/time-strip/index.js +19 -0
- package/registry/blocks/time-strip/strip-model.js +178 -0
- package/registry/blocks/time-strip/time-strip.jsx +260 -0
- package/registry/blocks/top-bar/README.md +149 -0
- package/registry/blocks/top-bar/block.json +35 -0
- package/registry/blocks/top-bar/demo.jsx +78 -0
- package/registry/blocks/top-bar/index.js +5 -0
- package/registry/blocks/top-bar/probe-rules.js +74 -0
- package/registry/blocks/top-bar/top-bar-model.js +152 -0
- package/registry/blocks/top-bar/top-bar.jsx +244 -0
- package/registry/blocks/trigger-picker/README.md +98 -0
- package/registry/blocks/trigger-picker/block.json +38 -0
- package/registry/blocks/trigger-picker/demo.jsx +59 -0
- package/registry/blocks/trigger-picker/index.js +3 -0
- package/registry/blocks/trigger-picker/trigger-picker.jsx +374 -0
- package/registry/blocks/whiteboard/README.md +20 -14
- package/registry/blocks/workflow-view/README.md +123 -0
- package/registry/blocks/workflow-view/block.json +10 -0
- package/registry/blocks/workflow-view/demo.jsx +90 -0
- package/registry/blocks/workflow-view/index.js +3 -0
- package/registry/blocks/workflow-view/workflow-view.jsx +203 -0
- package/src/cli.mjs +2 -2
- package/src/install.mjs +100 -22
- package/src/registry.mjs +37 -3
- package/templates/next/browser/lib/data.js +6 -1
- package/templates/next/files/README.md +909 -26
- package/templates/next/files/app/shell.jsx +54 -37
- package/templates/next/files/check/automation-format.ts +2103 -0
- package/templates/next/files/check/automation-package.ts +142 -0
- package/templates/next/files/check/run.mjs +1043 -12
- package/templates/next/files/components/ui/field.jsx +17 -4
- package/templates/next/files/components/ui/icon.jsx +13 -0
- package/templates/next/files/components/ui/index.js +3 -1
- package/templates/next/files/components/ui/layout.jsx +382 -0
- package/templates/next/files/components/ui/notice.jsx +70 -0
- package/templates/next/files/components/ui/page.jsx +124 -0
- package/templates/next/files/components/ui/stat.jsx +22 -7
- package/templates/next/files/components/ui/toast.jsx +6 -3
- package/templates/next/files/lib/automations.js +660 -0
- package/templates/next/files/lib/claw.js +244 -5
- package/templates/next/files/lib/door.js +92 -0
- package/templates/next/files/lib/hooks.js +1694 -6
- package/templates/next/files/lib/platform.js +611 -14
- package/templates/next/template.json +22 -3
package/r/spreadsheet.json
CHANGED
|
@@ -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
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.1/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.
|
|
86
|
+
"version": "0.8.1",
|
|
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.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.1/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.1/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.
|
|
62
|
+
"version": "0.8.1",
|
|
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
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "step-picker",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "StepPicker",
|
|
6
|
+
"description": "The two ways to choose a step of an automation: StepPicker, the popover a + on a connector opens (a search box, the kinds grouped Your apps / Think / Flow / Data / Tell, one line on what each does and whether it is free, paid or coming), and PaletteBar, the strip along the bottom of a canvas (one icon per kind). Both only say which kind a person chose; where it goes is the editor's call.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.1/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.1/r/automation-model.json"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": [],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "registry/blocks/step-picker/index.js",
|
|
15
|
+
"type": "registry:component",
|
|
16
|
+
"target": "@components/makersclaw/blocks/step-picker/index.js",
|
|
17
|
+
"content": "// The StepPicker block — `makersclaw add step-picker`. Fixed: compose it\n// through its props (README.md beside this file).\nexport { PaletteBar, StepPicker } from \"./step-picker\";\n"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "registry/blocks/step-picker/step-picker.jsx",
|
|
21
|
+
"type": "registry:component",
|
|
22
|
+
"target": "@components/makersclaw/blocks/step-picker/step-picker.jsx",
|
|
23
|
+
"content": "\"use client\";\n\n// Two ways to choose a step: the picker a + on a connector opens (search,\n// the kinds grouped, what each does), and the palette along the bottom of\n// a canvas (one icon per kind, grouped). Both read the catalog\n// (automation-model); both only say which kind a person chose. Where it\n// goes is the editor's call. Props in, callbacks out; tokens only.\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { Input, cx } from \"../../ui\";\nimport { Glyph, KINDS, isRunnable, paletteGroups } from \"../automation-model\";\n\nconst matches = (k, q) => {\n if (!q) return true;\n const hay = `${k.name} ${k.blurb} ${k.kind}`.toLowerCase();\n return q\n .toLowerCase()\n .split(/\\s+/)\n .every((w) => hay.includes(w));\n};\n\n/** How a kind is dressed: the agent solid in the app's accent, a model\n * call outlined in it, every other kind plain. */\nconst TILE = {\n plain: \"border-border bg-card text-muted-foreground\",\n model: \"border-primary/55 bg-card text-primary\",\n agent: \"border-primary bg-primary/15 text-primary\",\n};\n\n/**\n * onPick(kind) a kind was chosen (\"action\", \"condition\", \"llm\" …)\n * onClose() Escape, or a press outside\n * title the dialog's name (default \"Add a step\")\n * inline in the flow of a page (the outline), not floating\n * runnable a Set of the kinds the platform runs; the rest say \"soon\"\n * (default: automation-model's RUNNABLE)\n * className, style the floating panel's place (the editor sets it)\n */\nexport function StepPicker({ onPick, onClose, title = \"Add a step\", inline = false, runnable, className, style }) {\n const [q, setQ] = useState(\"\");\n const ref = useRef(null);\n const groups = useMemo(\n () =>\n paletteGroups()\n .map((g) => ({ ...g, kinds: g.kinds.filter((k) => matches(k, q)) }))\n .filter((g) => g.kinds.length),\n [q],\n );\n\n useEffect(() => {\n const onDown = (e) => {\n if (ref.current && !ref.current.contains(e.target)) onClose?.();\n };\n const onKey = (e) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n onClose?.();\n }\n };\n document.addEventListener(\"pointerdown\", onDown, true);\n document.addEventListener(\"keydown\", onKey, true);\n return () => {\n document.removeEventListener(\"pointerdown\", onDown, true);\n document.removeEventListener(\"keydown\", onKey, true);\n };\n }, [onClose]);\n\n return (\n <div\n ref={ref}\n role=\"dialog\"\n aria-label={title}\n style={style}\n onPointerDown={(e) => e.stopPropagation()}\n className={cx(\n \"z-20 flex min-w-0 flex-col gap-2 rounded-lg border border-border bg-popover p-2 text-popover-foreground\",\n inline ? \"w-full\" : \"absolute w-[330px] max-w-[calc(100%_-_1rem)] shadow-lg\",\n className,\n )}\n >\n <Input type=\"search\" placeholder=\"Search steps\" aria-label=\"Search steps\" value={q} autoFocus onChange={(e) => setQ(e.target.value)} />\n <div data-scroll className={cx(\"grid min-w-0 gap-2 overflow-y-auto\", inline ? \"max-h-72\" : \"max-h-[320px]\")}>\n {groups.length === 0 ? <p className=\"m-0 p-2 text-sm text-muted-foreground\">No step matches “{q}”.</p> : null}\n {groups.map((g) => (\n <section key={g.group} aria-label={g.label} className=\"grid gap-0.5\">\n <h4 className=\"m-0 px-2 pt-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground\">{g.label}</h4>\n {g.kinds.map((k) => (\n <button\n key={k.kind}\n type=\"button\"\n onClick={() => onPick?.(k.kind)}\n className=\"flex min-w-0 items-center gap-2.5 rounded-md border border-transparent px-2 py-1.5 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span className={cx(\"inline-flex size-9 shrink-0 items-center justify-center rounded-lg border\", TILE[k.tone] ?? TILE.plain)}>\n <Glyph name={k.glyph} size={18} />\n </span>\n <span className=\"grid min-w-0 flex-1 gap-0.5\">\n <b className=\"text-[13px] font-semibold\">{k.name}</b>\n <span className=\"text-xs leading-snug text-muted-foreground\">{k.blurb}</span>\n </span>\n <span className=\"shrink-0 rounded-full border border-border px-1.5 text-2xs text-muted-foreground\">{k.paid ? \"paid\" : !isRunnable(k.kind, runnable) ? \"soon\" : \"free\"}</span>\n </button>\n ))}\n </section>\n ))}\n </div>\n </div>\n );\n}\n\n/**\n * The bar along the bottom of a canvas: every kind, one icon each, grouped.\n * onAdd(kind) a kind was pressed\n */\nexport function PaletteBar({ onAdd, className }) {\n const groups = paletteGroups();\n return (\n <div role=\"toolbar\" aria-label=\"Add a step\" className={cx(\"flex max-w-full items-end gap-3 overflow-x-auto rounded-xl border border-border bg-card p-1.5 text-card-foreground shadow-md\", className)}>\n {groups.map((g) => (\n <div key={g.group} className=\"grid shrink-0 gap-1\">\n <span className=\"px-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground\">{g.label}</span>\n <div className=\"flex gap-0.5\">\n {g.kinds.map((k) => (\n <button\n key={k.kind}\n type=\"button\"\n title={`${k.name}: ${k.blurb}`}\n aria-label={`Add ${KINDS[k.kind].name}`}\n onClick={() => onAdd?.(k.kind)}\n className={cx(\n \"relative inline-flex size-9 items-center justify-center rounded-lg border border-transparent hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n k.tone === \"plain\" ? \"text-muted-foreground hover:text-foreground\" : \"text-primary\",\n )}\n >\n <Glyph name={k.glyph} size={16} />\n {k.mark ? <em className=\"absolute -bottom-0.5 -right-0.5 rounded-full bg-primary px-1 text-2xs font-bold not-italic leading-none text-primary-foreground\">{k.mark === \"model\" ? \"AI\" : \"A\"}</em> : null}\n </button>\n ))}\n </div>\n </div>\n ))}\n </div>\n );\n}\n"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "registry/blocks/step-picker/README.md",
|
|
27
|
+
"type": "registry:file",
|
|
28
|
+
"target": "@components/makersclaw/blocks/step-picker/README.md",
|
|
29
|
+
"content": "# StepPicker\n\nThe two ways to choose a step of an automation. They only say which kind a\nperson chose; where the step goes is the editor's call (`flow-editor` and\n`automation-editor` do it for you).\n\n- `StepPicker` — the popover a **+** on a connector opens: a search box,\n then the kinds grouped Your apps · Think · Flow · Data · Tell · More,\n each with one line on what it does and a tag: **free**, **paid** (it\n costs credits when it runs) or **soon** (the platform does not run it\n yet).\n- `PaletteBar` — the strip along the bottom of a canvas: one icon per\n kind, grouped, with an \"AI\" mark on a model call and an \"A\" on the agent.\n\n```jsx\nimport { StepPicker, PaletteBar } from \"../components/blocks/step-picker\"; // a Makersclaw app\nimport { StepPicker, PaletteBar } from \"@/components/makersclaw/blocks/step-picker\"; // a shadcn project\n```\n\n`makersclaw add step-picker` lays `automation-model` with it, which holds\nthe catalog both read. Client components. Tokens only: an agent step is\ndrawn in the app's accent (`primary`, as the kit's AgentMark draws the agent: solid for the agent, outlined for one model call) and everything else plain.\n\n## Props\n\n### StepPicker\n\n- `onPick(kind)` — a kind was chosen: `action` · `tool` · `llm` · `agent` ·\n `condition` · `loop` · `wait` · `stop` · `set` · `brain.read` · `search` ·\n `notify` · `automation`.\n- `onClose()` — Escape, or a press outside it.\n- `title` — the dialog's name for a screen reader (default \"Add a step\").\n- `inline` — in the flow of a page (the outline view), not floating over a\n canvas.\n- `runnable` — a `Set` of the kinds the platform runs. The rest say\n \"soon\". Default: the kinds the engine runs today.\n- `style`, `className` — where the floating panel sits. Never colour.\n\n### PaletteBar\n\n- `onAdd(kind)` — a kind was pressed.\n- `className`.\n\n## Never\n\n- Never offer a kind the platform cannot run without saying \"soon\": the\n save would refuse it later.\n- Never a price in the picker beyond free or paid: a step's price comes\n from the automation's estimate, on its card.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { StepPicker } from \"../components/blocks/step-picker\";\nimport { Button } from \"../components/ui\";\n\nexport default function AddStep({ onAdd }) {\n const [open, setOpen] = useState(false);\n return (\n <div className=\"relative\">\n <Button variant=\"secondary\" icon=\"plus\" onClick={() => setOpen(true)}>\n Add a step\n </Button>\n {open ? (\n <StepPicker\n onClose={() => setOpen(false)}\n onPick={(kind) => {\n setOpen(false);\n onAdd(kind);\n }}\n />\n ) : null}\n </div>\n );\n}\n```\n"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"categories": [
|
|
33
|
+
"automation",
|
|
34
|
+
"workflow",
|
|
35
|
+
"step",
|
|
36
|
+
"picker",
|
|
37
|
+
"palette",
|
|
38
|
+
"add",
|
|
39
|
+
"node",
|
|
40
|
+
"search",
|
|
41
|
+
"kinds",
|
|
42
|
+
"trigger",
|
|
43
|
+
"n8n",
|
|
44
|
+
"make"
|
|
45
|
+
],
|
|
46
|
+
"docs": "Installed to components/makersclaw/blocks/step-picker. Import: import { PaletteBar, StepPicker } from \"@/components/makersclaw/blocks/step-picker\". Props and an example: components/makersclaw/blocks/step-picker/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
47
|
+
"meta": {
|
|
48
|
+
"makersclaw": {
|
|
49
|
+
"version": "0.8.1",
|
|
50
|
+
"useWhen": "A screen lets a person add a step to an automation: from a + on the canvas or the outline (flow-editor does it for you), or from a button of your own. Not a general command palette (use Command)."
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|