makersclaw 0.2.0 → 0.3.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/THIRD-PARTY-NOTICES.md +180 -0
- package/package.json +3 -2
- package/r/annotate.json +62 -0
- package/r/approval-request.json +52 -0
- package/r/audio-player.json +74 -0
- package/r/auth-form.json +55 -0
- package/r/board.json +10 -6
- package/r/calendar-view.json +105 -0
- package/r/cart.json +62 -0
- package/r/chart.json +83 -0
- package/r/chat.json +95 -0
- package/r/code-editor.json +94 -0
- package/r/comment-box.json +64 -0
- package/r/contact-section.json +53 -0
- package/r/core.json +205 -8
- package/r/csv-import.json +63 -0
- package/r/data-table.json +69 -0
- package/r/diagram.json +74 -0
- package/r/doc-editor.json +82 -0
- package/r/doc-page.json +61 -0
- package/r/email-render.json +73 -0
- package/r/faq.json +50 -0
- package/r/file-upload.json +54 -0
- package/r/flow-canvas.json +93 -0
- package/r/footer.json +52 -0
- package/r/gantt.json +70 -0
- package/r/job-panel.json +12 -7
- package/r/json-editor.json +66 -0
- package/r/map.json +89 -0
- package/r/menubar.json +52 -0
- package/r/mind-map.json +67 -0
- package/r/navigation-menu.json +52 -0
- package/r/order-detail.json +54 -0
- package/r/org-chart.json +66 -0
- package/r/page-builder.json +105 -0
- package/r/payment-methods.json +51 -0
- package/r/pdf-document.json +79 -0
- package/r/pdf-viewer.json +88 -0
- package/r/product-detail.json +76 -0
- package/r/product-grid.json +65 -0
- package/r/record-form.json +67 -0
- package/r/record-notes.json +55 -0
- package/r/registry.json +3411 -29
- package/r/search-dialog.json +51 -0
- package/r/settings-list.json +66 -0
- package/r/setup-checklist.json +51 -0
- package/r/share-dialog.json +52 -0
- package/r/sidebar.json +69 -0
- package/r/signature-pad.json +72 -0
- package/r/sketch-canvas.json +99 -0
- package/r/spreadsheet.json +90 -0
- package/r/stat-cards.json +66 -0
- package/r/task-list.json +63 -0
- package/r/task-model.json +98 -0
- package/r/testimonials.json +52 -0
- package/r/transaction-list.json +53 -0
- package/r/whiteboard.json +66 -0
- package/registry/blocks/annotate/README.md +179 -0
- package/registry/blocks/annotate/annotate-data.js +250 -0
- package/registry/blocks/annotate/annotate.jsx +646 -0
- package/registry/blocks/annotate/block.json +10 -0
- package/registry/blocks/annotate/demo.jsx +135 -0
- package/registry/blocks/annotate/index.js +15 -0
- package/registry/blocks/approval-request/README.md +116 -0
- package/registry/blocks/approval-request/approval-request.jsx +386 -0
- package/registry/blocks/approval-request/block.json +10 -0
- package/registry/blocks/approval-request/demo.jsx +127 -0
- package/registry/blocks/approval-request/index.js +4 -0
- package/registry/blocks/audio-player/README.md +167 -0
- package/registry/blocks/audio-player/audio-player.jsx +623 -0
- package/registry/blocks/audio-player/block.json +14 -0
- package/registry/blocks/audio-player/colors.js +109 -0
- package/registry/blocks/audio-player/demo.jsx +209 -0
- package/registry/blocks/audio-player/index.js +6 -0
- package/registry/blocks/audio-player/time.js +67 -0
- package/registry/blocks/audio-player/wavesurfer.js +9 -0
- package/registry/blocks/auth-form/README.md +126 -0
- package/registry/blocks/auth-form/auth-form.jsx +568 -0
- package/registry/blocks/auth-form/block.json +10 -0
- package/registry/blocks/auth-form/demo.jsx +131 -0
- package/registry/blocks/auth-form/index.js +3 -0
- package/registry/blocks/board/README.md +55 -5
- package/registry/blocks/board/block.json +2 -2
- package/registry/blocks/board/board.jsx +230 -86
- package/registry/blocks/board/demo.jsx +103 -24
- package/registry/blocks/calendar-view/README.md +180 -0
- package/registry/blocks/calendar-view/agenda.jsx +147 -0
- package/registry/blocks/calendar-view/block.json +76 -0
- package/registry/blocks/calendar-view/calendar-model.js +442 -0
- package/registry/blocks/calendar-view/calendar.jsx +627 -0
- package/registry/blocks/calendar-view/demo.jsx +195 -0
- package/registry/blocks/calendar-view/draft.jsx +49 -0
- package/registry/blocks/calendar-view/index.js +39 -0
- package/registry/blocks/calendar-view/month.jsx +346 -0
- package/registry/blocks/calendar-view/paint.js +69 -0
- package/registry/blocks/calendar-view/time-grid.jsx +527 -0
- package/registry/blocks/calendar-view/tray.jsx +87 -0
- package/registry/blocks/cart/README.md +146 -0
- package/registry/blocks/cart/block.json +10 -0
- package/registry/blocks/cart/cart.jsx +597 -0
- package/registry/blocks/cart/demo.jsx +98 -0
- package/registry/blocks/cart/index.js +4 -0
- package/registry/blocks/cart/money.js +28 -0
- package/registry/blocks/chart/README.md +316 -0
- package/registry/blocks/chart/block.json +10 -0
- package/registry/blocks/chart/cartesian.jsx +642 -0
- package/registry/blocks/chart/core.jsx +262 -0
- package/registry/blocks/chart/demo.jsx +357 -0
- package/registry/blocks/chart/heatmap.jsx +336 -0
- package/registry/blocks/chart/index.js +8 -0
- package/registry/blocks/chart/pie.jsx +245 -0
- package/registry/blocks/chart/sparkline.jsx +88 -0
- package/registry/blocks/chat/README.md +273 -0
- package/registry/blocks/chat/attachment.jsx +95 -0
- package/registry/blocks/chat/block.json +10 -0
- package/registry/blocks/chat/chat.jsx +132 -0
- package/registry/blocks/chat/composer.jsx +364 -0
- package/registry/blocks/chat/demo.jsx +578 -0
- package/registry/blocks/chat/header.jsx +34 -0
- package/registry/blocks/chat/index.js +9 -0
- package/registry/blocks/chat/message.jsx +450 -0
- package/registry/blocks/chat/scroller.jsx +247 -0
- package/registry/blocks/chat/text.jsx +104 -0
- package/registry/blocks/code-editor/README.md +210 -0
- package/registry/blocks/code-editor/block.json +22 -0
- package/registry/blocks/code-editor/code-editor.jsx +534 -0
- package/registry/blocks/code-editor/codemirror.js +38 -0
- package/registry/blocks/code-editor/demo.jsx +205 -0
- package/registry/blocks/code-editor/index.js +8 -0
- package/registry/blocks/code-editor/json.js +317 -0
- package/registry/blocks/code-editor/sql.js +136 -0
- package/registry/blocks/code-editor/text.js +101 -0
- package/registry/blocks/code-editor/theme.js +186 -0
- package/registry/blocks/comment-box/README.md +123 -0
- package/registry/blocks/comment-box/block.json +10 -0
- package/registry/blocks/comment-box/comment-body.jsx +217 -0
- package/registry/blocks/comment-box/comment-box.jsx +198 -0
- package/registry/blocks/comment-box/comment-time.jsx +77 -0
- package/registry/blocks/comment-box/demo.jsx +141 -0
- package/registry/blocks/comment-box/index.js +8 -0
- package/registry/blocks/contact-section/README.md +152 -0
- package/registry/blocks/contact-section/block.json +11 -0
- package/registry/blocks/contact-section/contact-section.jsx +509 -0
- package/registry/blocks/contact-section/demo.jsx +93 -0
- package/registry/blocks/contact-section/index.js +4 -0
- package/registry/blocks/csv-import/README.md +133 -0
- package/registry/blocks/csv-import/block.json +13 -0
- package/registry/blocks/csv-import/csv-import.jsx +479 -0
- package/registry/blocks/csv-import/demo.jsx +106 -0
- package/registry/blocks/csv-import/import.js +200 -0
- package/registry/blocks/csv-import/index.js +7 -0
- package/registry/blocks/data-table/README.md +386 -0
- package/registry/blocks/data-table/block.json +10 -0
- package/registry/blocks/data-table/data-table.jsx +1164 -0
- package/registry/blocks/data-table/demo.jsx +588 -0
- package/registry/blocks/data-table/index.js +5 -0
- package/registry/blocks/data-table/model.js +169 -0
- package/registry/blocks/diagram/README.md +108 -0
- package/registry/blocks/diagram/block.json +13 -0
- package/registry/blocks/diagram/demo.jsx +113 -0
- package/registry/blocks/diagram/diagram.jsx +481 -0
- package/registry/blocks/diagram/index.js +7 -0
- package/registry/blocks/diagram/mermaid.js +52 -0
- package/registry/blocks/diagram/palette.js +313 -0
- package/registry/blocks/diagram/source.js +98 -0
- package/registry/blocks/doc-editor/README.md +215 -0
- package/registry/blocks/doc-editor/block.json +15 -0
- package/registry/blocks/doc-editor/demo.jsx +225 -0
- package/registry/blocks/doc-editor/doc-editor.jsx +291 -0
- package/registry/blocks/doc-editor/doc.js +309 -0
- package/registry/blocks/doc-editor/index.js +7 -0
- package/registry/blocks/doc-editor/schema.jsx +56 -0
- package/registry/blocks/doc-editor/shadcn-classes.js +110 -0
- package/registry/blocks/doc-editor/theme.js +82 -0
- package/registry/blocks/doc-page/README.md +119 -0
- package/registry/blocks/doc-page/autosave.js +104 -0
- package/registry/blocks/doc-page/block.json +10 -0
- package/registry/blocks/doc-page/demo.jsx +137 -0
- package/registry/blocks/doc-page/doc-page.jsx +276 -0
- package/registry/blocks/doc-page/index.js +4 -0
- package/registry/blocks/email-render/README.md +151 -0
- package/registry/blocks/email-render/block.json +14 -0
- package/registry/blocks/email-render/demo.jsx +153 -0
- package/registry/blocks/email-render/doc-email.jsx +210 -0
- package/registry/blocks/email-render/email.jsx +221 -0
- package/registry/blocks/email-render/index.js +10 -0
- package/registry/blocks/email-render/render.js +50 -0
- package/registry/blocks/email-render/theme.js +215 -0
- package/registry/blocks/faq/README.md +101 -0
- package/registry/blocks/faq/block.json +11 -0
- package/registry/blocks/faq/demo.jsx +89 -0
- package/registry/blocks/faq/faq.jsx +220 -0
- package/registry/blocks/faq/index.js +4 -0
- package/registry/blocks/file-upload/README.md +138 -0
- package/registry/blocks/file-upload/block.json +10 -0
- package/registry/blocks/file-upload/demo.jsx +204 -0
- package/registry/blocks/file-upload/file-upload.jsx +635 -0
- package/registry/blocks/file-upload/index.js +4 -0
- package/registry/blocks/flow-canvas/README.md +420 -0
- package/registry/blocks/flow-canvas/block.json +13 -0
- package/registry/blocks/flow-canvas/board.js +819 -0
- package/registry/blocks/flow-canvas/chrome.jsx +649 -0
- package/registry/blocks/flow-canvas/context.jsx +35 -0
- package/registry/blocks/flow-canvas/demo.jsx +249 -0
- package/registry/blocks/flow-canvas/edges.jsx +107 -0
- package/registry/blocks/flow-canvas/flow-canvas.jsx +1329 -0
- package/registry/blocks/flow-canvas/index.js +41 -0
- package/registry/blocks/flow-canvas/nodes.jsx +392 -0
- package/registry/blocks/flow-canvas/theme.js +174 -0
- package/registry/blocks/footer/README.md +133 -0
- package/registry/blocks/footer/block.json +11 -0
- package/registry/blocks/footer/demo.jsx +119 -0
- package/registry/blocks/footer/footer.jsx +406 -0
- package/registry/blocks/footer/index.js +4 -0
- package/registry/blocks/gantt/README.md +220 -0
- package/registry/blocks/gantt/block.json +10 -0
- package/registry/blocks/gantt/demo.jsx +148 -0
- package/registry/blocks/gantt/gantt.jsx +923 -0
- package/registry/blocks/gantt/index.js +7 -0
- package/registry/blocks/gantt/rows.jsx +468 -0
- package/registry/blocks/gantt/schedule.js +278 -0
- package/registry/blocks/job-panel/README.md +80 -0
- package/registry/blocks/job-panel/block.json +3 -3
- package/registry/blocks/job-panel/demo.jsx +230 -3
- package/registry/blocks/job-panel/job-panel.jsx +169 -3
- package/registry/blocks/json-editor/README.md +133 -0
- package/registry/blocks/json-editor/block.json +10 -0
- package/registry/blocks/json-editor/demo.jsx +92 -0
- package/registry/blocks/json-editor/index.js +7 -0
- package/registry/blocks/json-editor/json-editor.jsx +305 -0
- package/registry/blocks/json-editor/json-tree.jsx +393 -0
- package/registry/blocks/json-editor/tree.js +225 -0
- package/registry/blocks/map/README.md +221 -0
- package/registry/blocks/map/block.json +14 -0
- package/registry/blocks/map/demo.jsx +201 -0
- package/registry/blocks/map/geo.js +198 -0
- package/registry/blocks/map/index.js +8 -0
- package/registry/blocks/map/list.jsx +128 -0
- package/registry/blocks/map/map-view.jsx +715 -0
- package/registry/blocks/map/maplibre.js +30 -0
- package/registry/blocks/map/marks.jsx +146 -0
- package/registry/blocks/map/theme.js +142 -0
- package/registry/blocks/menubar/README.md +109 -0
- package/registry/blocks/menubar/block.json +10 -0
- package/registry/blocks/menubar/demo.jsx +128 -0
- package/registry/blocks/menubar/index.js +4 -0
- package/registry/blocks/menubar/menubar.jsx +199 -0
- package/registry/blocks/mind-map/README.md +206 -0
- package/registry/blocks/mind-map/block.json +10 -0
- package/registry/blocks/mind-map/demo.jsx +103 -0
- package/registry/blocks/mind-map/index.js +31 -0
- package/registry/blocks/mind-map/mind-map.jsx +1125 -0
- package/registry/blocks/mind-map/topic.jsx +143 -0
- package/registry/blocks/mind-map/tree.js +645 -0
- package/registry/blocks/navigation-menu/README.md +124 -0
- package/registry/blocks/navigation-menu/block.json +10 -0
- package/registry/blocks/navigation-menu/demo.jsx +84 -0
- package/registry/blocks/navigation-menu/index.js +4 -0
- package/registry/blocks/navigation-menu/navigation-menu.jsx +448 -0
- package/registry/blocks/order-detail/README.md +109 -0
- package/registry/blocks/order-detail/block.json +10 -0
- package/registry/blocks/order-detail/demo.jsx +88 -0
- package/registry/blocks/order-detail/index.js +4 -0
- package/registry/blocks/order-detail/order-detail.jsx +354 -0
- package/registry/blocks/org-chart/README.md +145 -0
- package/registry/blocks/org-chart/block.json +10 -0
- package/registry/blocks/org-chart/demo.jsx +94 -0
- package/registry/blocks/org-chart/index.js +7 -0
- package/registry/blocks/org-chart/org-chart.jsx +631 -0
- package/registry/blocks/org-chart/org.js +245 -0
- package/registry/blocks/org-chart/person.jsx +96 -0
- package/registry/blocks/page-builder/README.md +257 -0
- package/registry/blocks/page-builder/block.json +14 -0
- package/registry/blocks/page-builder/demo.jsx +145 -0
- package/registry/blocks/page-builder/grapes.js +18 -0
- package/registry/blocks/page-builder/index.js +11 -0
- package/registry/blocks/page-builder/page-builder.jsx +704 -0
- package/registry/blocks/page-builder/page-view.jsx +33 -0
- package/registry/blocks/page-builder/page.js +118 -0
- package/registry/blocks/page-builder/panels.jsx +588 -0
- package/registry/blocks/page-builder/sections.js +209 -0
- package/registry/blocks/page-builder/theme.js +87 -0
- package/registry/blocks/page-builder/thumbs.jsx +189 -0
- package/registry/blocks/page-builder/tokens.js +329 -0
- package/registry/blocks/payment-methods/README.md +104 -0
- package/registry/blocks/payment-methods/block.json +10 -0
- package/registry/blocks/payment-methods/demo.jsx +71 -0
- package/registry/blocks/payment-methods/index.js +4 -0
- package/registry/blocks/payment-methods/payment-methods.jsx +302 -0
- package/registry/blocks/pdf-document/README.md +213 -0
- package/registry/blocks/pdf-document/block.json +13 -0
- package/registry/blocks/pdf-document/demo.jsx +136 -0
- package/registry/blocks/pdf-document/index.js +33 -0
- package/registry/blocks/pdf-document/money.js +83 -0
- package/registry/blocks/pdf-document/pdf-document.jsx +144 -0
- package/registry/blocks/pdf-document/primitives.jsx +271 -0
- package/registry/blocks/pdf-document/quote.jsx +179 -0
- package/registry/blocks/pdf-document/theme.js +193 -0
- package/registry/blocks/pdf-viewer/README.md +191 -0
- package/registry/blocks/pdf-viewer/block.json +14 -0
- package/registry/blocks/pdf-viewer/demo.jsx +252 -0
- package/registry/blocks/pdf-viewer/helpers.js +270 -0
- package/registry/blocks/pdf-viewer/index.js +8 -0
- package/registry/blocks/pdf-viewer/notes.jsx +195 -0
- package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1368 -0
- package/registry/blocks/pdf-viewer/pdfjs.js +37 -0
- package/registry/blocks/pdf-viewer/ssr-shim-end.js +6 -0
- package/registry/blocks/pdf-viewer/ssr-shim.js +44 -0
- package/registry/blocks/product-detail/README.md +217 -0
- package/registry/blocks/product-detail/block.json +10 -0
- package/registry/blocks/product-detail/demo.jsx +233 -0
- package/registry/blocks/product-detail/index.js +7 -0
- package/registry/blocks/product-detail/price.jsx +115 -0
- package/registry/blocks/product-detail/product-detail.jsx +348 -0
- package/registry/blocks/product-detail/product-media.jsx +102 -0
- package/registry/blocks/product-detail/product-options.jsx +144 -0
- package/registry/blocks/product-grid/README.md +162 -0
- package/registry/blocks/product-grid/block.json +10 -0
- package/registry/blocks/product-grid/demo.jsx +255 -0
- package/registry/blocks/product-grid/index.js +5 -0
- package/registry/blocks/product-grid/product-card.jsx +232 -0
- package/registry/blocks/product-grid/product-grid.jsx +235 -0
- package/registry/blocks/record-form/README.md +196 -0
- package/registry/blocks/record-form/block.json +10 -0
- package/registry/blocks/record-form/demo.jsx +525 -0
- package/registry/blocks/record-form/index.js +4 -0
- package/registry/blocks/record-form/record-fields.jsx +457 -0
- package/registry/blocks/record-form/record-form.jsx +415 -0
- package/registry/blocks/record-form/rules.js +81 -0
- package/registry/blocks/record-notes/README.md +139 -0
- package/registry/blocks/record-notes/block.json +10 -0
- package/registry/blocks/record-notes/demo.jsx +179 -0
- package/registry/blocks/record-notes/index.js +4 -0
- package/registry/blocks/record-notes/record-notes.jsx +266 -0
- package/registry/blocks/search-dialog/README.md +108 -0
- package/registry/blocks/search-dialog/block.json +10 -0
- package/registry/blocks/search-dialog/demo.jsx +116 -0
- package/registry/blocks/search-dialog/index.js +3 -0
- package/registry/blocks/search-dialog/search-dialog.jsx +235 -0
- package/registry/blocks/settings-list/README.md +171 -0
- package/registry/blocks/settings-list/block.json +10 -0
- package/registry/blocks/settings-list/demo.jsx +333 -0
- package/registry/blocks/settings-list/index.js +4 -0
- package/registry/blocks/settings-list/row-state.jsx +93 -0
- package/registry/blocks/settings-list/settings-list.jsx +349 -0
- package/registry/blocks/settings-list/settings-matrix.jsx +118 -0
- package/registry/blocks/setup-checklist/README.md +142 -0
- package/registry/blocks/setup-checklist/block.json +10 -0
- package/registry/blocks/setup-checklist/demo.jsx +159 -0
- package/registry/blocks/setup-checklist/index.js +4 -0
- package/registry/blocks/setup-checklist/setup-checklist.jsx +446 -0
- package/registry/blocks/share-dialog/README.md +113 -0
- package/registry/blocks/share-dialog/block.json +10 -0
- package/registry/blocks/share-dialog/demo.jsx +140 -0
- package/registry/blocks/share-dialog/index.js +3 -0
- package/registry/blocks/share-dialog/share-dialog.jsx +384 -0
- package/registry/blocks/sidebar/README.md +190 -0
- package/registry/blocks/sidebar/block.json +43 -0
- package/registry/blocks/sidebar/demo.jsx +577 -0
- package/registry/blocks/sidebar/index.js +4 -0
- package/registry/blocks/sidebar/sidebar-panel.jsx +149 -0
- package/registry/blocks/sidebar/sidebar.jsx +864 -0
- package/registry/blocks/signature-pad/README.md +137 -0
- package/registry/blocks/signature-pad/block.json +13 -0
- package/registry/blocks/signature-pad/colors.js +115 -0
- package/registry/blocks/signature-pad/demo.jsx +94 -0
- package/registry/blocks/signature-pad/index.js +6 -0
- package/registry/blocks/signature-pad/pad.js +7 -0
- package/registry/blocks/signature-pad/signature-pad.jsx +434 -0
- package/registry/blocks/signature-pad/signature.js +68 -0
- package/registry/blocks/sketch-canvas/README.md +280 -0
- package/registry/blocks/sketch-canvas/block.json +13 -0
- package/registry/blocks/sketch-canvas/demo.jsx +159 -0
- package/registry/blocks/sketch-canvas/excalidraw.js +40 -0
- package/registry/blocks/sketch-canvas/index.js +9 -0
- package/registry/blocks/sketch-canvas/palette.js +226 -0
- package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1188 -0
- package/registry/blocks/sketch-canvas/sketch.js +179 -0
- package/registry/blocks/sketch-canvas/ssr-shim-end.js +6 -0
- package/registry/blocks/sketch-canvas/ssr-shim.js +45 -0
- package/registry/blocks/sketch-canvas/stamps.js +104 -0
- package/registry/blocks/sketch-canvas/theme.js +131 -0
- package/registry/blocks/spreadsheet/README.md +307 -0
- package/registry/blocks/spreadsheet/block.json +13 -0
- package/registry/blocks/spreadsheet/cells.jsx +455 -0
- package/registry/blocks/spreadsheet/chrome.jsx +325 -0
- package/registry/blocks/spreadsheet/demo.jsx +177 -0
- package/registry/blocks/spreadsheet/grid.js +387 -0
- package/registry/blocks/spreadsheet/index.js +34 -0
- package/registry/blocks/spreadsheet/spreadsheet.jsx +1732 -0
- package/registry/blocks/spreadsheet/types.js +473 -0
- package/registry/blocks/stat-cards/README.md +173 -0
- package/registry/blocks/stat-cards/block.json +10 -0
- package/registry/blocks/stat-cards/demo.jsx +257 -0
- package/registry/blocks/stat-cards/index.js +4 -0
- package/registry/blocks/stat-cards/stat-cards.jsx +307 -0
- package/registry/blocks/stat-cards/usage.jsx +243 -0
- package/registry/blocks/task-list/README.md +176 -0
- package/registry/blocks/task-list/block.json +10 -0
- package/registry/blocks/task-list/demo.jsx +134 -0
- package/registry/blocks/task-list/index.js +5 -0
- package/registry/blocks/task-list/task-list.jsx +907 -0
- package/registry/blocks/task-model/README.md +455 -0
- package/registry/blocks/task-model/block.json +167 -0
- package/registry/blocks/task-model/changes.js +89 -0
- package/registry/blocks/task-model/dates.js +371 -0
- package/registry/blocks/task-model/demo.jsx +215 -0
- package/registry/blocks/task-model/dnd.jsx +240 -0
- package/registry/blocks/task-model/index.js +97 -0
- package/registry/blocks/task-model/model.js +919 -0
- package/registry/blocks/task-model/parts.jsx +449 -0
- package/registry/blocks/task-model/toolbar.jsx +271 -0
- package/registry/blocks/testimonials/README.md +139 -0
- package/registry/blocks/testimonials/block.json +11 -0
- package/registry/blocks/testimonials/demo.jsx +137 -0
- package/registry/blocks/testimonials/index.js +4 -0
- package/registry/blocks/testimonials/testimonials.jsx +430 -0
- package/registry/blocks/transaction-list/README.md +126 -0
- package/registry/blocks/transaction-list/block.json +10 -0
- package/registry/blocks/transaction-list/demo.jsx +79 -0
- package/registry/blocks/transaction-list/index.js +4 -0
- package/registry/blocks/transaction-list/transaction-list.jsx +383 -0
- package/registry/blocks/whiteboard/README.md +179 -0
- package/registry/blocks/whiteboard/block.json +10 -0
- package/registry/blocks/whiteboard/demo.jsx +169 -0
- package/registry/blocks/whiteboard/index.js +6 -0
- package/registry/blocks/whiteboard/templates.js +150 -0
- package/registry/blocks/whiteboard/whiteboard.jsx +721 -0
- package/src/cli.mjs +19 -6
- package/src/install.mjs +18 -0
- package/src/registry.mjs +38 -4
- package/src/shadcn.mjs +14 -1
- package/templates/next/files/README.md +145 -11
- package/templates/next/files/app/globals.css +55 -4
- package/templates/next/files/check/run.mjs +3 -3
- package/templates/next/files/components/ui/avatar.jsx +141 -0
- package/templates/next/files/components/ui/breadcrumb.jsx +149 -0
- package/templates/next/files/components/ui/button-group.jsx +70 -0
- package/templates/next/files/components/ui/button.jsx +132 -12
- package/templates/next/files/components/ui/calendar.jsx +609 -0
- package/templates/next/files/components/ui/carousel.jsx +281 -0
- package/templates/next/files/components/ui/collapsible.jsx +208 -0
- package/templates/next/files/components/ui/combobox.jsx +608 -0
- package/templates/next/files/components/ui/command.jsx +577 -0
- package/templates/next/files/components/ui/context-menu.jsx +191 -0
- package/templates/next/files/components/ui/date-picker.jsx +360 -0
- package/templates/next/files/components/ui/distribution.jsx +117 -0
- package/templates/next/files/components/ui/feedback.jsx +143 -0
- package/templates/next/files/components/ui/field.jsx +115 -3
- package/templates/next/files/components/ui/file-input.jsx +234 -0
- package/templates/next/files/components/ui/icon.jsx +130 -8
- package/templates/next/files/components/ui/image.jsx +143 -0
- package/templates/next/files/components/ui/index.js +47 -2
- package/templates/next/files/components/ui/input-group.jsx +213 -0
- package/templates/next/files/components/ui/input-otp.jsx +170 -0
- package/templates/next/files/components/ui/item.jsx +206 -0
- package/templates/next/files/components/ui/menu.jsx +454 -82
- package/templates/next/files/components/ui/notice.jsx +126 -14
- package/templates/next/files/components/ui/pagination.jsx +156 -0
- package/templates/next/files/components/ui/popover.jsx +284 -0
- package/templates/next/files/components/ui/radio.jsx +193 -0
- package/templates/next/files/components/ui/rating.jsx +131 -0
- package/templates/next/files/components/ui/resizable.jsx +338 -0
- package/templates/next/files/components/ui/scroll-area.jsx +123 -0
- package/templates/next/files/components/ui/separator.jsx +119 -0
- package/templates/next/files/components/ui/sheet.jsx +96 -11
- package/templates/next/files/components/ui/slider.jsx +174 -0
- package/templates/next/files/components/ui/stat.jsx +574 -0
- package/templates/next/files/components/ui/steps.jsx +338 -0
- package/templates/next/files/components/ui/swatch.jsx +33 -0
- package/templates/next/files/components/ui/table.jsx +130 -0
- package/templates/next/files/components/ui/toast.jsx +440 -0
- package/templates/next/files/components/ui/toggle.jsx +195 -0
- package/templates/next/files/components/ui/tooltip.jsx +270 -0
- package/templates/next/template.json +264 -8
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "spreadsheet",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Spreadsheet",
|
|
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
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.1/r/core.json"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@tanstack/react-virtual@3.14.13"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/blocks/spreadsheet/index.js",
|
|
16
|
+
"type": "registry:component",
|
|
17
|
+
"target": "@components/makersclaw/blocks/spreadsheet/index.js",
|
|
18
|
+
"content": "// The Spreadsheet block — `makersclaw add spreadsheet`. Fixed: compose it\n// through its props (README.md beside this file). The helpers (the column\n// types, the view, TSV, paste and fill) are plain functions and work\n// anywhere, the server included. Blocks built on this one (base-view,\n// csv-import) import it, never @tanstack/react-virtual directly.\nexport { Spreadsheet } from \"./spreadsheet\";\nexport { CellValue, CellPanel, OptionBadge, editorFor } from \"./cells\";\nexport { TYPE_ORDER, sortWords } from \"./chrome\";\nexport {\n COLUMN_TYPES,\n typeOf,\n parseCell,\n convertValue,\n guessType,\n parseNumberText,\n parseDateText,\n optionValue,\n isBlank,\n} from \"./types\";\nexport {\n emptyView,\n normalizeView,\n orderedColumns,\n visibleColumns,\n processRows,\n passesFilters,\n parseTSV,\n toTSV,\n pasteChanges,\n fillChanges,\n clearChanges,\n applyChanges,\n invertChanges,\n} from \"./grid\";\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "registry/blocks/spreadsheet/spreadsheet.jsx",
|
|
22
|
+
"type": "registry:component",
|
|
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"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "registry/blocks/spreadsheet/cells.jsx",
|
|
28
|
+
"type": "registry:component",
|
|
29
|
+
"target": "@components/makersclaw/blocks/spreadsheet/cells.jsx",
|
|
30
|
+
"content": "\"use client\";\n\n// How each column type is DRAWN in a cell and EDITED in place. The logic\n// (parse, format, sort, filter) is types.js; this file is keyed by a\n// type's `editor` name. A custom type (the `types` prop) may bring its own\n// `render(value, column, ctx)` and `Editor` component.\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Avatar, AvatarGroup, Badge, Calendar, Command, Icon, Rating, cx, panelClass, usePopoverPosition } from \"../../ui\";\nimport { optionOf, optionValue } from \"./types\";\n\nconst asList = (v) => (Array.isArray(v) ? v : v == null || v === \"\" ? [] : [v]);\n\n// ── display ─────────────────────────────────────────────────────────────\n\nexport function OptionBadge({ option, fallback }) {\n return (\n <Badge variant=\"soft\" hue={option?.hue ?? 1} className=\"max-w-full shrink-0 truncate\">\n {option?.label ?? fallback}\n </Badge>\n );\n}\n\n/** A cell's content, not editing. `active` is the selected cell (a rating\n * is an input there). */\nexport function CellValue({ value, column, type, ctx, active, readOnly, onSet }) {\n if (type.render) return type.render(value, column, ctx);\n switch (type.editor) {\n case \"number\":\n return <span className=\"block min-w-0 flex-1 truncate text-right tabular-nums\">{type.format(value, column, ctx)}</span>;\n case \"select\": {\n const list = asList(value);\n if (!list.length) return null;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-1 overflow-hidden\">\n {list.map((v) => (\n <OptionBadge key={v} option={optionOf(column, v)} fallback={v} />\n ))}\n </span>\n );\n }\n case \"person\": {\n const people = asList(value).map((id) => (ctx.people ?? []).find((p) => p.id === id) ?? { id, name: String(id) });\n if (!people.length) return null;\n if (people.length > 1) return <AvatarGroup people={people} size=\"xs\" max={3} />;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Avatar name={people[0].name} src={people[0].src} size=\"xs\" />\n <span className=\"truncate\">{people[0].name}</span>\n </span>\n );\n }\n case \"toggle\":\n return (\n <span className=\"flex flex-1 justify-center\">\n <span\n aria-hidden=\"true\"\n data-toggle=\"\"\n className={cx(\n \"grid size-4 place-items-center rounded-sm border transition-colors duration-150\",\n value ? \"border-primary bg-primary text-primary-foreground\" : \"border-input bg-background\",\n !readOnly && \"cursor-pointer\",\n )}\n >\n {value ? <Icon name=\"check\" size={12} /> : null}\n </span>\n </span>\n );\n case \"rating\": {\n const max = column.max ?? 5;\n if (active && !readOnly) {\n return <Rating value={value || 0} max={max} size=\"sm\" label={column.name} onChange={(n) => onSet?.(n === value ? 0 : n)} />;\n }\n return value ? <Rating value={value} max={max} size=\"sm\" label={column.name} /> : null;\n }\n case \"url\": {\n if (!value) return null;\n const href = /^[a-z]+:/i.test(value) ? value : `https://${value}`;\n return (\n <span className=\"flex min-w-0 flex-1 items-center gap-1\">\n <span className=\"truncate text-primary\">{type.format(value, column, ctx)}</span>\n {/^https?:/i.test(href) ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n tabIndex={-1}\n aria-label={`Open ${type.format(value, column, ctx)}`}\n onPointerDown={(e) => e.stopPropagation()}\n className={cx(\"ml-auto grid size-6 shrink-0 place-items-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground\", active ? \"opacity-100\" : \"opacity-0 group-hover/row:opacity-100\")}\n >\n <Icon name=\"external\" size={14} />\n </a>\n ) : null}\n </span>\n );\n }\n default:\n return <span className=\"block min-w-0 flex-1 truncate\">{type.format(value, column, ctx)}</span>;\n }\n}\n\n// ── the floating panel an editor opens beside its cell ──────────────────\n\n/** A top-layer panel under `anchorRef` (it floats above the grid's scroll\n * box, which would clip it). Dismissed by a press outside it and the cell. */\nexport function CellPanel({ anchorRef, onDismiss, width = \"sm\", className, label, children }) {\n const ref = useRef(null);\n const dismiss = useRef(onDismiss);\n dismiss.current = onDismiss;\n usePopoverPosition(anchorRef, ref, { side: \"bottom\", align: \"start\", offset: 4 });\n useLayoutEffect(() => {\n const p = ref.current;\n if (p && !p.matches(\":popover-open\")) p.showPopover?.();\n return () => {\n if (p?.matches(\":popover-open\")) p.hidePopover?.();\n };\n }, []);\n useEffect(() => {\n function down(e) {\n const inside = ref.current?.contains(e.target) || anchorRef.current?.parentElement?.contains(e.target);\n if (!inside) dismiss.current?.();\n }\n document.addEventListener(\"pointerdown\", down, true);\n return () => document.removeEventListener(\"pointerdown\", down, true);\n }, [anchorRef]);\n return (\n <div ref={ref} popover=\"manual\" role=\"dialog\" aria-label={label} className={panelClass(width, cx(\"overflow-hidden p-0 text-sm\", className))}>\n {children}\n </div>\n );\n}\n\n// ── editors ─────────────────────────────────────────────────────────────\n// Every editor gets { value, column, type, ctx, initial, today, onCommit,\n// onCancel }. onCommit(value, move, extra) — move: \"down\" · \"up\" · \"right\"\n// · \"left\" · null (stay); extra.create: new options for a select column.\n\nconst TAB_MOVE = (e) => (e.shiftKey ? \"left\" : \"right\");\n\n/** Text, number, currency, link: an input laid over the cell. */\nfunction TextEditor({ value, column, type, ctx, initial, onCommit, onCancel }) {\n const [text, setText] = useState(() => (initial != null ? initial : type.toText(value, column, ctx)));\n const [error, setError] = useState(null);\n const input = useRef(null);\n const anchor = useRef(null);\n const done = useRef(false);\n\n useLayoutEffect(() => {\n const el = input.current;\n if (!el) return;\n el.focus({ preventScroll: true });\n const end = el.value.length;\n el.setSelectionRange?.(end, end);\n }, []);\n\n function commit(move) {\n const res = type.parse(text, column, ctx);\n if (\"error\" in res) {\n setError(res.error);\n input.current?.focus({ preventScroll: true });\n return false;\n }\n done.current = true;\n onCommit(res.value, move, res.create ? { create: res.create } : undefined);\n return true;\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n {/* raw: an input the size of the cell with no chrome of its own; the kit's Input is a 44px field with a border. */}\n <input\n ref={input}\n value={text}\n aria-label={column.name}\n aria-invalid={error ? true : undefined}\n inputMode={type.editor === \"number\" ? \"decimal\" : type.editor === \"url\" ? \"url\" : undefined}\n spellCheck={type.editor === \"text\"}\n onChange={(e) => {\n setText(e.target.value);\n if (error) setError(null);\n }}\n onKeyDown={(e) => {\n if (e.nativeEvent.isComposing) return;\n if (e.key === \"Enter\") {\n e.preventDefault();\n commit(e.shiftKey ? \"up\" : \"down\");\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n commit(TAB_MOVE(e));\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n done.current = true;\n onCancel();\n }\n e.stopPropagation();\n }}\n onBlur={() => {\n if (done.current) return;\n // Leaving with text that does not parse keeps the old value.\n const res = type.parse(text, column, ctx);\n done.current = true;\n if (\"error\" in res) onCancel();\n else onCommit(res.value, null, res.create ? { create: res.create } : undefined);\n }}\n className={cx(\n \"absolute inset-0 z-10 h-full w-full bg-background px-2 text-sm text-foreground outline-none\",\n \"rounded-none shadow-[inset_0_0_0_2px_var(--color-primary)]\",\n type.editor === \"number\" && \"text-right tabular-nums\",\n error && \"shadow-[inset_0_0_0_2px_var(--color-destructive)]\",\n )}\n />\n {error ? (\n <CellPanel anchorRef={anchor} onDismiss={() => {}} width=\"auto\" label=\"Problem\">\n <p role=\"alert\" className=\"flex items-center gap-2 px-3 py-2 text-xs text-destructive\">\n <Icon name=\"warning\" size={14} />\n {error}\n </p>\n </CellPanel>\n ) : null}\n </>\n );\n}\n\n/** A date: type it, or pick it on the calendar. */\nfunction DateEditor({ value, column, type, ctx, initial, today, onCommit, onCancel }) {\n const anchor = useRef(null);\n const input = useRef(null);\n const [text, setText] = useState(() => (initial != null ? initial : value ? type.format(value, column, ctx) : \"\"));\n const [error, setError] = useState(null);\n const typed = type.parse(text, column, ctx);\n const shown = !(\"error\" in typed) && typed.value ? typed.value : value || null;\n\n useLayoutEffect(() => {\n input.current?.focus({ preventScroll: true });\n input.current?.select?.();\n }, []);\n\n function commitText(move) {\n if (\"error\" in typed) {\n setError(typed.error);\n return;\n }\n onCommit(typed.value, move);\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-10 shadow-[inset_0_0_0_2px_var(--color-primary)]\" />\n <CellPanel anchorRef={anchor} onDismiss={onCancel} width=\"auto\" label={column.name}>\n <div\n className=\"grid gap-2 p-2\"\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n onCancel();\n }\n e.stopPropagation();\n }}\n >\n {/* raw: a compact date text box inside a 36px-row picker; the kit's Input is a 44px field. */}\n <input\n ref={input}\n value={text}\n placeholder=\"Type a date, or pick one\"\n aria-label={`${column.name}, as text`}\n aria-invalid={error ? true : undefined}\n onChange={(e) => {\n setText(e.target.value);\n setError(null);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n commitText(\"down\");\n } else if (e.key === \"Tab\" && !e.shiftKey && !text) {\n // Tab with nothing typed walks into the calendar.\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n commitText(TAB_MOVE(e));\n }\n }}\n className={cx(\n \"h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n error && \"border-destructive\",\n )}\n />\n {error ? (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n ) : null}\n <Calendar\n mode=\"single\"\n size=\"sm\"\n value={shown}\n defaultMonth={shown ? shown.slice(0, 7) : undefined}\n today={today}\n onChange={(d) => onCommit(d ?? null, null)}\n />\n <div className=\"flex items-center justify-between border-t border-border pt-2\">\n {/* raw: a text-size link-button in a dense picker footer; kit Button's link variant is text-sm with no hit area of its own. */}\n <button\n type=\"button\"\n className=\"rounded-sm px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n onClick={() => onCommit(today ?? new Date().toISOString().slice(0, 10), null)}\n >\n Today\n </button>\n {value ? (\n // raw: the same dense footer link.\n <button\n type=\"button\"\n className=\"rounded-sm px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n onClick={() => onCommit(null, null)}\n >\n Clear\n </button>\n ) : null}\n </div>\n </div>\n </CellPanel>\n </>\n );\n}\n\n/** Single and multiple select, and people: a searchable list. A single\n * pick commits; a multiple one stays open and commits on close. */\nfunction PickEditor({ value, column, type, ctx, initial, canCreate, onCommit, onCancel }) {\n const anchor = useRef(null);\n const person = type.editor === \"person\";\n const multiple = person ? Boolean(column.multiple) : Boolean(type.multiple);\n const [query, setQuery] = useState(initial ?? \"\");\n const [draft, setDraft] = useState(() => asList(value));\n const [created, setCreated] = useState([]);\n const draftRef = useRef(draft);\n draftRef.current = draft;\n const options = person\n ? (ctx.people ?? []).map((p) => ({ value: p.id, label: p.name, description: p.email, icon: <Avatar name={p.name} src={p.src} size=\"xs\" /> }))\n : [...(column.options ?? []), ...created].map((o) => ({ value: o.value, label: o.label, icon: <span aria-hidden=\"true\" className={cx(\"size-2 rounded-full\", HUE_DOT[o.hue ?? 1])} /> }));\n\n const exact = options.some((o) => o.label.toLowerCase() === query.trim().toLowerCase());\n const items = options.map((o) => ({\n ...o,\n icon: multiple ? (\n <span className={cx(\"grid size-4 place-items-center rounded-sm border\", draft.includes(o.value) ? \"border-primary bg-primary text-primary-foreground\" : \"border-input\")}>\n {draft.includes(o.value) ? <Icon name=\"check\" size={12} /> : null}\n </span>\n ) : (\n o.icon\n ),\n description: !multiple && draft[0] === o.value ? \"Selected\" : o.description,\n }));\n if (!person && canCreate && column.allowNew !== false && query.trim() && !exact) {\n items.push({ value: \"__create\", label: `Create “${query.trim()}”`, icon: \"plus\", keywords: [query] });\n }\n if (!multiple && draft.length) items.push({ value: \"__clear\", label: \"Clear\", icon: \"x\" });\n\n function finish(move = null) {\n const list = draftRef.current;\n const next = multiple ? list : list[0] ?? null;\n onCommit(next, move, created.length ? { create: created } : undefined);\n }\n\n function pick(item) {\n if (item.value === \"__clear\") return onCommit(null, null);\n if (item.value === \"__create\") {\n const label = query.trim();\n const o = { value: optionValue(label, [...(column.options ?? []), ...created].map((x) => x.value)), label, hue: (((column.options?.length ?? 0) + created.length) % 5) + 1 };\n setCreated((c) => [...c, o]);\n setQuery(\"\");\n if (multiple) setDraft((d) => [...d, o.value]);\n else onCommit(o.value, null, { create: [...created, o] });\n return;\n }\n if (multiple) {\n setDraft((d) => (d.includes(item.value) ? d.filter((x) => x !== item.value) : [...d, item.value]));\n setQuery(\"\");\n } else onCommit(item.value, null, created.length ? { create: created } : undefined);\n }\n\n return (\n <>\n <span ref={anchor} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-10 shadow-[inset_0_0_0_2px_var(--color-primary)]\" />\n <CellPanel anchorRef={anchor} onDismiss={() => (multiple ? finish() : onCancel())} width=\"sm\" label={column.name}>\n <div\n onKeyDown={(e) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n if (multiple) finish();\n else onCancel();\n } else if (e.key === \"Tab\") {\n e.preventDefault();\n if (multiple) finish(TAB_MOVE(e));\n else onCancel();\n } else if (e.key === \"Backspace\" && multiple && !query && draft.length) {\n setDraft((d) => d.slice(0, -1));\n }\n e.stopPropagation();\n }}\n >\n {multiple && draft.length ? (\n <div className=\"flex flex-wrap gap-1 border-b border-border p-2\">\n {draft.map((v) =>\n person ? (\n <Badge key={v} variant=\"soft\">\n {(ctx.people ?? []).find((p) => p.id === v)?.name ?? v}\n </Badge>\n ) : (\n <OptionBadge key={v} option={optionOf({ options: [...(column.options ?? []), ...created] }, v)} fallback={v} />\n ),\n )}\n </div>\n ) : null}\n <Command\n bare\n autoFocus\n items={items}\n query={query}\n onQueryChange={setQuery}\n onSelect={pick}\n label={column.name}\n placeholder={person ? \"Find a person\" : canCreate && column.allowNew !== false ? \"Find or create an option\" : \"Find an option\"}\n empty={person ? \"No one by that name.\" : \"No options yet — type one to create it.\"}\n listClassName=\"max-h-64\"\n />\n {multiple ? (\n <p className=\"border-t border-border px-3 py-2 text-2xs text-muted-foreground\">Enter to toggle · Esc when done</p>\n ) : null}\n </div>\n </CellPanel>\n </>\n );\n}\n\nconst HUE_DOT = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\n\n/** The editor for a type, or null when it edits in place (a checkbox, a\n * rating) or not at all. */\nexport function editorFor(type) {\n if (type.Editor) return type.Editor;\n switch (type.editor) {\n case \"text\":\n case \"number\":\n case \"url\":\n return TextEditor;\n case \"date\":\n return DateEditor;\n case \"select\":\n case \"person\":\n return PickEditor;\n default:\n return null;\n }\n}\n"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "registry/blocks/spreadsheet/chrome.jsx",
|
|
34
|
+
"type": "registry:component",
|
|
35
|
+
"target": "@components/makersclaw/blocks/spreadsheet/chrome.jsx",
|
|
36
|
+
"content": "\"use client\";\n\n// The panels around the grid: filter, sort, fields, add a field, the\n// keyboard sheet. Each edits one part of the view and hands it back.\nimport { useState } from \"react\";\nimport { Avatar, Button, Combobox, Field, Icon, IconButton, Input, KbdGroup, Select, Switch, cx } from \"../../ui\";\nimport { COLUMN_TYPES, parseNumberText, typeOf } from \"./types\";\n\nlet seq = 0;\nexport const uid = (p) => `${p}-${Date.now().toString(36)}${(seq++).toString(36)}`;\n\n/** What \"ascending\" and \"descending\" mean for a type, in words. */\nexport function sortWords(type) {\n switch (type.name) {\n case \"number\":\n case \"currency\":\n case \"rating\":\n return { asc: \"1 → 9\", desc: \"9 → 1\" };\n case \"date\":\n return { asc: \"Oldest first\", desc: \"Newest first\" };\n case \"checkbox\":\n return { asc: \"Checked first\", desc: \"Unchecked first\" };\n case \"select\":\n case \"multi-select\":\n return { asc: \"First → last\", desc: \"Last → first\" };\n default:\n return { asc: \"A → Z\", desc: \"Z → A\" };\n }\n}\n\n// ── filter ──────────────────────────────────────────────────────────────\n\nfunction NumberValue({ value, onChange }) {\n const [text, setText] = useState(value == null || value === \"\" ? \"\" : String(value));\n const n = parseNumberText(text);\n return (\n <Input\n inputMode=\"decimal\"\n placeholder=\"Number\"\n aria-label=\"Value\"\n value={text}\n invalid={text !== \"\" && Number.isNaN(n)}\n onChange={(e) => {\n setText(e.target.value);\n const next = parseNumberText(e.target.value);\n onChange(next === null ? \"\" : Number.isNaN(next) ? \"\" : next);\n }}\n />\n );\n}\n\nfunction FilterValue({ op, column, value, onChange, ctx }) {\n switch (op?.arg) {\n case \"text\":\n return <Input placeholder=\"Text\" aria-label=\"Value\" value={value ?? \"\"} onChange={(e) => onChange(e.target.value)} />;\n case \"number\":\n return <NumberValue key={column.key + op.id} value={value} onChange={onChange} />;\n case \"date\":\n return <Input type=\"date\" aria-label=\"Value\" value={value ?? \"\"} onChange={(e) => onChange(e.target.value)} />;\n case \"options\":\n return (\n <Combobox\n multiple\n label=\"Values\"\n placeholder=\"Pick options\"\n options={(column.options ?? []).map((o) => ({ value: o.value, label: o.label }))}\n value={Array.isArray(value) ? value : []}\n onValueChange={(v) => onChange(v)}\n />\n );\n case \"person\":\n return (\n <Combobox\n multiple\n label=\"People\"\n placeholder=\"Pick people\"\n options={(ctx.people ?? []).map((p) => ({ value: p.id, label: p.name, icon: <Avatar name={p.name} src={p.src} size=\"xs\" /> }))}\n value={Array.isArray(value) ? value : []}\n onValueChange={(v) => onChange(v)}\n />\n );\n default:\n return <span aria-hidden=\"true\" />;\n }\n}\n\nexport function FilterPanel({ columns, filters, onChange, ctx, custom }) {\n const byKey = new Map(columns.map((c) => [c.key, c]));\n function set(id, patch) {\n onChange(filters.map((f) => (f.id === id ? { ...f, ...patch } : f)));\n }\n function add() {\n const col = columns[0];\n if (!col) return;\n onChange([...filters, { id: uid(\"f\"), key: col.key, op: typeOf(col, custom).allFilters[0].id, value: \"\" }]);\n }\n return (\n <div className=\"grid w-[38rem] max-w-full gap-3\">\n <p className=\"text-sm font-medium\">Filter rows</p>\n {filters.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">Nothing filtered. Add a condition to show only the rows you need.</p>\n ) : (\n <ul className=\"grid gap-2\">\n {filters.map((f, i) => {\n const col = byKey.get(f.key) ?? columns[0];\n const t = typeOf(col, custom);\n const op = t.allFilters.find((o) => o.id === f.op) ?? t.allFilters[0];\n return (\n <li\n key={f.id}\n className=\"grid gap-2 max-sm:rounded-md max-sm:border max-sm:border-border max-sm:p-2 sm:grid-cols-[3rem_minmax(0,1fr)_minmax(0,9rem)_minmax(0,1fr)_auto] sm:items-center\"\n >\n <div className=\"flex items-center justify-between sm:contents\">\n <span className=\"text-xs text-muted-foreground\">{i === 0 ? \"Where\" : \"and\"}</span>\n <IconButton icon=\"x\" size=\"sm\" label=\"Remove this condition\" className=\"sm:hidden\" onClick={() => onChange(filters.filter((x) => x.id !== f.id))} />\n </div>\n <Select\n aria-label=\"Field\"\n value={col.key}\n options={columns.map((c) => ({ value: c.key, label: c.name }))}\n onChange={(e) => {\n const next = byKey.get(e.target.value);\n set(f.id, { key: next.key, op: typeOf(next, custom).allFilters[0].id, value: \"\" });\n }}\n />\n <Select\n aria-label=\"Condition\"\n value={op.id}\n options={t.allFilters.map((o) => ({ value: o.id, label: o.label }))}\n onChange={(e) => set(f.id, { op: e.target.value, value: t.allFilters.find((o) => o.id === e.target.value)?.arg === op.arg ? f.value : \"\" })}\n />\n <div className=\"min-w-0\">\n <FilterValue key={f.key + op.arg} op={op} column={col} value={f.value} ctx={ctx} onChange={(value) => set(f.id, { value })} />\n </div>\n <IconButton icon=\"x\" size=\"sm\" label=\"Remove this condition\" className=\"max-sm:hidden\" onClick={() => onChange(filters.filter((x) => x.id !== f.id))} />\n </li>\n );\n })}\n </ul>\n )}\n <div className=\"flex items-center justify-between gap-2\">\n <Button variant=\"quiet\" size=\"sm\" icon=\"plus\" onClick={add} disabled={!columns.length}>\n Add condition\n </Button>\n {filters.length ? (\n <Button variant=\"quiet\" size=\"sm\" onClick={() => onChange([])}>\n Clear all\n </Button>\n ) : null}\n </div>\n </div>\n );\n}\n\n// ── sort ────────────────────────────────────────────────────────────────\n\nexport function SortPanel({ columns, sort, onChange, custom }) {\n const used = new Set(sort.map((s) => s.key));\n const free = columns.filter((c) => !used.has(c.key));\n return (\n <div className=\"grid w-[26rem] max-w-full gap-3\">\n <p className=\"text-sm font-medium\">Sort rows</p>\n {sort.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">Rows show in the order they were added. Pick a field to sort by.</p>\n ) : (\n <ol className=\"grid gap-2\">\n {sort.map((s, i) => {\n const col = columns.find((c) => c.key === s.key);\n if (!col) return null;\n const words = sortWords(typeOf(col, custom));\n return (\n <li key={s.key} className=\"grid grid-cols-[3rem_minmax(0,1fr)_minmax(0,9rem)_auto] items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">{i === 0 ? \"Sort by\" : \"then\"}</span>\n <Select\n aria-label=\"Field\"\n value={s.key}\n options={[col, ...free].map((c) => ({ value: c.key, label: c.name }))}\n onChange={(e) => onChange(sort.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)))}\n />\n <Select\n aria-label=\"Direction\"\n value={s.dir}\n options={[\n { value: \"asc\", label: words.asc },\n { value: \"desc\", label: words.desc },\n ]}\n onChange={(e) => onChange(sort.map((x, j) => (j === i ? { ...x, dir: e.target.value } : x)))}\n />\n <IconButton icon=\"x\" size=\"sm\" label=\"Remove this sort\" onClick={() => onChange(sort.filter((_, j) => j !== i))} />\n </li>\n );\n })}\n </ol>\n )}\n <div className=\"flex items-center justify-between gap-2\">\n <Button variant=\"quiet\" size=\"sm\" icon=\"plus\" disabled={!free.length} onClick={() => onChange([...sort, { key: free[0].key, dir: \"asc\" }])}>\n {sort.length ? \"Add another sort\" : \"Add a sort\"}\n </Button>\n {sort.length ? (\n <Button variant=\"quiet\" size=\"sm\" onClick={() => onChange([])}>\n Clear\n </Button>\n ) : null}\n </div>\n </div>\n );\n}\n\n// ── fields (show / hide) ────────────────────────────────────────────────\n\nexport function FieldsPanel({ columns, hidden, onChange, custom }) {\n const off = new Set(hidden);\n const [q, setQ] = useState(\"\");\n const shown = columns.filter((c) => c.name.toLowerCase().includes(q.trim().toLowerCase()));\n const visibleCount = columns.filter((c) => !off.has(c.key)).length;\n return (\n <div className=\"grid w-72 max-w-full gap-2\">\n {columns.length > 8 ? <Input placeholder=\"Find a field\" aria-label=\"Find a field\" value={q} onChange={(e) => setQ(e.target.value)} /> : null}\n <ul className=\"grid max-h-80 overflow-y-auto\">\n {shown.map((c) => {\n const t = typeOf(c, custom);\n const on = !off.has(c.key);\n return (\n <li key={c.key} className=\"flex items-center gap-2\">\n <Icon name={t.icon} size={14} className=\"shrink-0 text-muted-foreground\" />\n <Switch\n id={`field-${c.key}`}\n checked={on}\n disabled={on && visibleCount <= 1}\n onChange={() => onChange(on ? [...hidden, c.key] : hidden.filter((k) => k !== c.key))}\n label={c.name}\n className=\"min-w-0 flex-1 flex-row-reverse justify-between\"\n />\n </li>\n );\n })}\n </ul>\n <div className=\"flex items-center justify-between gap-2 border-t border-border pt-2\">\n <Button variant=\"quiet\" size=\"sm\" onClick={() => onChange(columns.slice(1).map((c) => c.key))} disabled={visibleCount <= 1}>\n Hide all\n </Button>\n <Button variant=\"quiet\" size=\"sm\" onClick={() => onChange([])} disabled={!hidden.length}>\n Show all\n </Button>\n </div>\n </div>\n );\n}\n\n// ── add a field ─────────────────────────────────────────────────────────\n\nexport const TYPE_ORDER = [\"text\", \"number\", \"currency\", \"date\", \"select\", \"multi-select\", \"person\", \"checkbox\", \"url\", \"rating\"];\n\nexport function AddFieldPanel({ onAdd, names, custom }) {\n const [name, setName] = useState(\"\");\n const [type, setType] = useState(\"text\");\n const kinds = [...TYPE_ORDER, ...Object.keys(custom ?? {}).filter((k) => !TYPE_ORDER.includes(k))];\n const clash = names.some((n) => n.toLowerCase() === name.trim().toLowerCase());\n function submit(e) {\n e.preventDefault();\n if (!name.trim() || clash) return;\n onAdd({ name: name.trim(), type });\n setName(\"\");\n setType(\"text\");\n }\n return (\n <form className=\"grid w-80 max-w-full gap-3\" onSubmit={submit}>\n <Field id=\"new-field-name\" label=\"Field name\" error={clash ? \"A field already has that name\" : undefined}>\n <Input id=\"new-field-name\" placeholder=\"Renewal date\" value={name} onChange={(e) => setName(e.target.value)} autoComplete=\"off\" />\n </Field>\n <div className=\"grid gap-1.5\">\n <p className=\"text-sm font-medium\" id=\"new-field-type\">\n Type\n </p>\n <div role=\"radiogroup\" aria-labelledby=\"new-field-type\" className=\"grid grid-cols-2 gap-1\">\n {kinds.map((k) => {\n const t = typeOf({ type: k }, custom);\n const on = type === k;\n return (\n <Button key={k} variant={on ? \"secondary\" : \"quiet\"} size=\"sm\" align=\"start\" icon={t.icon} role=\"radio\" aria-checked={on} onClick={() => setType(k)} className={cx(\"font-normal\", on && \"ring-1 ring-primary\")}>\n {t.label}\n </Button>\n );\n })}\n </div>\n </div>\n <Button type=\"submit\" variant=\"primary\" size=\"sm\" disabled={!name.trim() || clash}>\n Add field\n </Button>\n </form>\n );\n}\n\n// ── keys ────────────────────────────────────────────────────────────────\n\nconst KEYS = [\n [[\"↑\", \"↓\", \"←\", \"→\"], \"Move\"],\n [[\"⇧\", \"↑\"], \"Select more\"],\n [[\"↵\"], \"Edit (or start typing)\"],\n [[\"Esc\"], \"Cancel an edit\"],\n [[\"⌫\"], \"Clear cells\"],\n [[\"⌘\", \"C\"], \"Copy · ⌘V paste\"],\n [[\"⌘\", \"D\"], \"Fill down\"],\n [[\"⌘\", \"Z\"], \"Undo · ⇧⌘Z redo\"],\n [[\"Space\"], \"Tick a box · open a row\"],\n [[\"⇧\", \"Space\"], \"Select the row\"],\n];\n\nexport function ShortcutsPanel() {\n return (\n <div className=\"grid w-72 gap-2\">\n <p className=\"text-sm font-medium\">Keyboard</p>\n <ul className=\"grid gap-1.5\">\n {KEYS.map(([keys, what]) => (\n <li key={what} className=\"flex items-center justify-between gap-3 text-sm\">\n <span className=\"text-muted-foreground\">{what}</span>\n <KbdGroup keys={keys} />\n </li>\n ))}\n </ul>\n </div>\n );\n}\n\nexport { COLUMN_TYPES };\n"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "registry/blocks/spreadsheet/types.js",
|
|
40
|
+
"type": "registry:component",
|
|
41
|
+
"target": "@components/makersclaw/blocks/spreadsheet/types.js",
|
|
42
|
+
"content": "// The column-type registry: what each kind of column holds, how it reads\n// and writes text, sorts and filters. Plain functions — no React, so they\n// run on the server, in a test, in csv-import. How a type is DRAWN and\n// EDITED lives in cells.jsx, keyed by `editor` below.\n//\n// A type is:\n// label what a person calls it (\"Currency\")\n// icon a kit glyph for the header\n// align start · end (numbers line up on the right)\n// width the default column width, px\n// editor how it is edited in a cell: text · number · date · select ·\n// person · toggle (flips in place) · rating · url · none\n// empty(v) true when the cell holds nothing\n// parse(text, col, ctx) → { value } | { error } — typed or pasted text in.\n// A select may add `create: [{ value, label }]` (options that\n// do not exist yet)\n// toText(v, col, ctx) the text a copy puts on the clipboard and an\n// edit starts from (\"1250.5\", \"2026-09-23\", \"Won, Lost\")\n// format(v, col, ctx) the text a cell shows (\"$1,250.50\", \"Sep 23, 2026\")\n// compare(a, b, col, ctx) the sort order; empties always sort last\n// filters the conditions the Filter panel offers: [{ id, label, arg,\n// test(v, arg, col, ctx) }] — `arg` names the value control:\n// text · number · date · option · options · person · none\n//\n// `ctx` is { people, locale, currency }: what a type reads beyond its own\n// column (the person list, the reader's locale).\n\nconst collator = new Intl.Collator(undefined, { numeric: true, sensitivity: \"base\" });\n\nconst isBlank = (v) => v == null || v === \"\" || (Array.isArray(v) && v.length === 0);\nconst str = (v) => (v == null ? \"\" : String(v));\nconst lc = (v) => str(v).toLowerCase();\n\n// ── numbers ─────────────────────────────────────────────────────────────\n\n/** \"$1,250.50\", \"(300)\", \"1 250,5\", \"-12\" → a number, or null for blank,\n * or NaN when it is not one. */\nexport function parseNumberText(text) {\n let s = str(text).trim();\n if (!s) return null;\n let neg = false;\n if (/^\\(.*\\)$/.test(s)) {\n neg = true;\n s = s.slice(1, -1);\n }\n s = s.replace(/[\\s ]/g, \"\").replace(/^[^\\d.,+-]+|[^\\d.,]+$/g, \"\");\n if (s.startsWith(\"-\")) {\n neg = !neg;\n s = s.slice(1);\n } else if (s.startsWith(\"+\")) s = s.slice(1);\n // A comma is a decimal point only when it is the one separator and two\n // or fewer digits follow it (\"12,5\"); else it groups thousands.\n const commas = (s.match(/,/g) || []).length;\n const dots = (s.match(/\\./g) || []).length;\n if (commas === 1 && dots === 0 && /,\\d{1,2}$/.test(s)) s = s.replace(\",\", \".\");\n else if (dots > 1 && commas <= 1) s = s.replace(/\\./g, \"\").replace(\",\", \".\");\n else s = s.replace(/,/g, \"\");\n if (!/^\\d*\\.?\\d+$|^\\d+\\.$/.test(s)) return NaN;\n const n = Number(s);\n return neg ? -n : n;\n}\n\nfunction numberFormat(col, ctx, currency) {\n const digits = col?.precision ?? (currency ? 2 : undefined);\n const opts = currency\n ? { style: \"currency\", currency: col?.currency || ctx?.currency || \"USD\", minimumFractionDigits: digits, maximumFractionDigits: digits }\n : { maximumFractionDigits: digits ?? 2, minimumFractionDigits: col?.precision ?? 0 };\n return cachedFormat(\"n\", ctx?.locale, opts, () => new Intl.NumberFormat(ctx?.locale, opts), () => new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }));\n}\n\n// Building an Intl formatter costs far more than using one, and a sort or\n// a search formats every cell: one per locale and options, kept.\nconst FORMATS = new Map();\nfunction cachedFormat(kind, locale, opts, make, fallback) {\n const key = `${kind}|${locale ?? \"\"}|${JSON.stringify(opts)}`;\n let f = FORMATS.get(key);\n if (!f) {\n try {\n f = make();\n } catch {\n f = fallback();\n }\n FORMATS.set(key, f);\n }\n return f;\n}\n\nconst numberCompare = (a, b) => a - b;\n\nconst NUMBER_FILTERS = [\n { id: \"eq\", label: \"=\", arg: \"number\", test: (v, a) => v === a },\n { id: \"ne\", label: \"≠\", arg: \"number\", test: (v, a) => v !== a },\n { id: \"gt\", label: \">\", arg: \"number\", test: (v, a) => v > a },\n { id: \"lt\", label: \"<\", arg: \"number\", test: (v, a) => v < a },\n { id: \"gte\", label: \"≥\", arg: \"number\", test: (v, a) => v >= a },\n { id: \"lte\", label: \"≤\", arg: \"number\", test: (v, a) => v <= a },\n];\n\n// ── dates ───────────────────────────────────────────────────────────────\n\nconst MONTHS = [\"jan\", \"feb\", \"mar\", \"apr\", \"may\", \"jun\", \"jul\", \"aug\", \"sep\", \"oct\", \"nov\", \"dec\"];\nconst pad = (n) => String(n).padStart(2, \"0\");\n\nfunction validDay(y, m, d) {\n if (!(y >= 1000 && y <= 9999 && m >= 1 && m <= 12 && d >= 1)) return null;\n const last = new Date(Date.UTC(y, m, 0)).getUTCDate();\n return d <= last ? `${y}-${pad(m)}-${pad(d)}` : null;\n}\n\n/** Text → \"YYYY-MM-DD\", null for blank, or undefined when it is no day.\n * ISO first, then 9/23/2026 (or 23/9/2026 with order \"dmy\", or when the\n * first number cannot be a month), then \"Sep 23, 2026\" / \"23 Sep 2026\". */\nexport function parseDateText(text, order = \"mdy\") {\n const s = str(text).trim();\n if (!s) return null;\n let m = /^(\\d{4})-(\\d{1,2})-(\\d{1,2})(?:[T\\s].*)?$/.exec(s);\n if (m) return validDay(+m[1], +m[2], +m[3]) ?? undefined;\n m = /^(\\d{1,2})[/.-](\\d{1,2})[/.-](\\d{2}|\\d{4})$/.exec(s);\n if (m) {\n let [a, b] = [+m[1], +m[2]];\n const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];\n if (order === \"dmy\" || a > 12) [a, b] = [b, a];\n return validDay(y, a, b) ?? undefined;\n }\n m = /^([a-z]{3,})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?,?\\s+(\\d{4})$/i.exec(s);\n if (m) {\n const mo = MONTHS.indexOf(m[1].slice(0, 3).toLowerCase());\n return mo < 0 ? undefined : validDay(+m[3], mo + 1, +m[2]) ?? undefined;\n }\n m = /^(\\d{1,2})(?:st|nd|rd|th)?\\s+([a-z]{3,})\\.?,?\\s+(\\d{4})$/i.exec(s);\n if (m) {\n const mo = MONTHS.indexOf(m[2].slice(0, 3).toLowerCase());\n return mo < 0 ? undefined : validDay(+m[3], mo + 1, +m[1]) ?? undefined;\n }\n return undefined;\n}\n\nfunction formatDay(v, ctx) {\n if (!v) return \"\";\n const [y, m, d] = String(v).split(\"-\").map(Number);\n if (!y || !m || !d) return str(v);\n const opts = { month: \"short\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" };\n const fmt = cachedFormat(\"d\", ctx?.locale, opts, () => new Intl.DateTimeFormat(ctx?.locale, opts), () => new Intl.DateTimeFormat(undefined, opts));\n return fmt.format(new Date(Date.UTC(y, m - 1, d)));\n}\n\n// ── options (select, multi-select) ──────────────────────────────────────\n\nconst optionsOf = (col) => col?.options ?? [];\nexport const optionOf = (col, value) => optionsOf(col).find((o) => o.value === value);\nconst labelOf = (col, value) => optionOf(col, value)?.label ?? str(value);\n\n/** A new option's value from its label: \"Closed won\" → \"closed-won\". */\nexport function optionValue(label, taken = []) {\n const base = lc(label).trim().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\") || \"option\";\n let v = base;\n for (let i = 2; taken.includes(v); i++) v = `${base}-${i}`;\n return v;\n}\n\n// Hues 1–5 cycle through new options, so a created one is never grey.\nfunction nextHue(col, extra = 0) {\n return ((optionsOf(col).length + extra) % 5) + 1;\n}\n\nfunction matchOption(col, text) {\n const t = lc(text).trim();\n return optionsOf(col).find((o) => lc(o.label) === t || lc(o.value) === t);\n}\n\nfunction parseOptions(text, col, multiple) {\n const s = str(text).trim();\n if (!s) return { value: multiple ? [] : null };\n const parts = multiple ? s.split(/\\s*[,;\\n]\\s*/).filter(Boolean) : [s];\n const value = [];\n const create = [];\n for (const p of parts) {\n const hit = matchOption(col, p) ?? create.find((c) => lc(c.label) === lc(p));\n if (hit) {\n if (!value.includes(hit.value)) value.push(hit.value);\n continue;\n }\n if (col?.allowNew === false) return { error: `“${p}” is not one of the options` };\n const o = { value: optionValue(p, [...optionsOf(col).map((x) => x.value), ...create.map((x) => x.value)]), label: p, hue: nextHue(col, create.length) };\n create.push(o);\n value.push(o.value);\n }\n return create.length ? { value: multiple ? value : value[0], create } : { value: multiple ? value : value[0] };\n}\n\nconst optionRank = (col, v) => {\n const i = optionsOf(col).findIndex((o) => o.value === v);\n return i < 0 ? Infinity : i;\n};\n\n// ── people ──────────────────────────────────────────────────────────────\n\nconst personOf = (ctx, id) => (ctx?.people ?? []).find((p) => p.id === id);\nconst personName = (ctx, id) => personOf(ctx, id)?.name ?? str(id);\nconst asList = (v) => (Array.isArray(v) ? v : isBlank(v) ? [] : [v]);\n\nfunction parsePeople(text, col, ctx) {\n const s = str(text).trim();\n if (!s) return { value: col?.multiple ? [] : null };\n const parts = col?.multiple ? s.split(/\\s*[,;\\n]\\s*/).filter(Boolean) : [s];\n const ids = [];\n for (const p of parts) {\n const t = lc(p);\n const hit = (ctx?.people ?? []).find((x) => lc(x.name) === t || lc(x.email) === t || lc(x.id) === t || lc(x.name).split(\" \")[0] === t);\n if (!hit) return { error: `No one called “${p}”` };\n if (!ids.includes(hit.id)) ids.push(hit.id);\n }\n return { value: col?.multiple ? ids : ids[0] };\n}\n\n// ── the registry ────────────────────────────────────────────────────────\n\nconst TEXT_FILTERS = [\n { id: \"contains\", label: \"contains\", arg: \"text\", test: (v, a) => lc(v).includes(lc(a)) },\n { id: \"not-contains\", label: \"does not contain\", arg: \"text\", test: (v, a) => !lc(v).includes(lc(a)) },\n { id: \"is\", label: \"is\", arg: \"text\", test: (v, a) => lc(v).trim() === lc(a).trim() },\n];\n\nexport const COLUMN_TYPES = {\n text: {\n label: \"Text\",\n icon: \"type\",\n align: \"start\",\n width: 220,\n editor: \"text\",\n parse: (t) => ({ value: str(t) }),\n toText: (v) => str(v),\n format: (v) => str(v),\n compare: (a, b) => collator.compare(str(a), str(b)),\n filters: TEXT_FILTERS,\n },\n number: {\n label: \"Number\",\n icon: \"hash\",\n align: \"end\",\n width: 120,\n editor: \"number\",\n parse: (t) => {\n const n = parseNumberText(t);\n return Number.isNaN(n) ? { error: \"That is not a number\" } : { value: n };\n },\n toText: (v) => (typeof v === \"number\" ? String(v) : str(v)),\n format: (v, col, ctx) => (typeof v === \"number\" ? numberFormat(col, ctx, false).format(v) : str(v)),\n compare: numberCompare,\n filters: NUMBER_FILTERS,\n },\n currency: {\n label: \"Currency\",\n icon: \"dollar\",\n align: \"end\",\n width: 130,\n editor: \"number\",\n parse: (t) => {\n const n = parseNumberText(t);\n return Number.isNaN(n) ? { error: \"That is not an amount\" } : { value: n };\n },\n toText: (v) => (typeof v === \"number\" ? String(v) : str(v)),\n format: (v, col, ctx) => (typeof v === \"number\" ? numberFormat(col, ctx, true).format(v) : str(v)),\n compare: numberCompare,\n filters: NUMBER_FILTERS,\n },\n date: {\n label: \"Date\",\n icon: \"calendar\",\n align: \"start\",\n width: 140,\n editor: \"date\",\n parse: (t, col) => {\n const d = parseDateText(t, col?.dateOrder);\n return d === undefined ? { error: \"That is not a date — try 2026-09-23\" } : { value: d };\n },\n toText: (v) => str(v),\n format: (v, col, ctx) => formatDay(v, ctx),\n compare: (a, b) => (a < b ? -1 : a > b ? 1 : 0),\n filters: [\n { id: \"is\", label: \"is\", arg: \"date\", test: (v, a) => v === a },\n { id: \"before\", label: \"is before\", arg: \"date\", test: (v, a) => v < a },\n { id: \"after\", label: \"is after\", arg: \"date\", test: (v, a) => v > a },\n ],\n },\n select: {\n label: \"Single select\",\n icon: \"chevron-down\",\n align: \"start\",\n width: 150,\n editor: \"select\",\n parse: (t, col) => parseOptions(t, col, false),\n toText: (v, col) => (isBlank(v) ? \"\" : labelOf(col, v)),\n format: (v, col) => (isBlank(v) ? \"\" : labelOf(col, v)),\n compare: (a, b, col) => optionRank(col, a) - optionRank(col, b) || collator.compare(str(a), str(b)),\n filters: [\n { id: \"any\", label: \"is any of\", arg: \"options\", test: (v, a) => asList(a).includes(v) },\n { id: \"none\", label: \"is none of\", arg: \"options\", test: (v, a) => !asList(a).includes(v) },\n ],\n },\n \"multi-select\": {\n label: \"Multiple select\",\n icon: \"tag\",\n align: \"start\",\n width: 200,\n editor: \"select\",\n multiple: true,\n parse: (t, col) => parseOptions(t, col, true),\n toText: (v, col) => asList(v).map((x) => labelOf(col, x)).join(\", \"),\n format: (v, col) => asList(v).map((x) => labelOf(col, x)).join(\", \"),\n compare: (a, b, col) => optionRank(col, asList(a)[0]) - optionRank(col, asList(b)[0]) || asList(a).length - asList(b).length,\n filters: [\n { id: \"any\", label: \"has any of\", arg: \"options\", test: (v, a) => asList(v).some((x) => asList(a).includes(x)) },\n { id: \"all\", label: \"has all of\", arg: \"options\", test: (v, a) => asList(a).every((x) => asList(v).includes(x)) },\n { id: \"none\", label: \"has none of\", arg: \"options\", test: (v, a) => !asList(v).some((x) => asList(a).includes(x)) },\n ],\n },\n person: {\n label: \"Person\",\n icon: \"user\",\n align: \"start\",\n width: 170,\n editor: \"person\",\n parse: parsePeople,\n toText: (v, col, ctx) => asList(v).map((id) => personName(ctx, id)).join(\", \"),\n format: (v, col, ctx) => asList(v).map((id) => personName(ctx, id)).join(\", \"),\n compare: (a, b, col, ctx) => collator.compare(personName(ctx, asList(a)[0]), personName(ctx, asList(b)[0])),\n filters: [\n { id: \"any\", label: \"is any of\", arg: \"person\", test: (v, a) => asList(v).some((x) => asList(a).includes(x)) },\n { id: \"none\", label: \"is none of\", arg: \"person\", test: (v, a) => !asList(v).some((x) => asList(a).includes(x)) },\n ],\n },\n checkbox: {\n label: \"Checkbox\",\n icon: \"square-check\",\n align: \"center\",\n width: 96,\n editor: \"toggle\",\n // A checkbox is never \"empty\": unchecked is a value.\n empty: () => false,\n parse: (t) => {\n const s = lc(t).trim();\n if ([\"true\", \"yes\", \"y\", \"1\", \"x\", \"✓\", \"✔\", \"checked\", \"on\", \"done\"].includes(s)) return { value: true };\n if ([\"false\", \"no\", \"n\", \"0\", \"\", \"unchecked\", \"off\"].includes(s)) return { value: false };\n return { error: \"Use yes or no\" };\n },\n toText: (v) => (v ? \"true\" : \"false\"),\n format: (v) => (v ? \"Yes\" : \"No\"),\n compare: (a, b) => Number(Boolean(b)) - Number(Boolean(a)),\n filters: [\n { id: \"checked\", label: \"is checked\", arg: \"none\", test: (v) => Boolean(v) },\n { id: \"unchecked\", label: \"is not checked\", arg: \"none\", test: (v) => !v },\n ],\n },\n url: {\n label: \"Link\",\n icon: \"link\",\n align: \"start\",\n width: 200,\n editor: \"url\",\n parse: (t) => {\n const s = str(t).trim();\n if (!s) return { value: \"\" };\n if (/\\s/.test(s) || !/[.:]/.test(s)) return { error: \"That does not look like a link\" };\n return { value: s };\n },\n toText: (v) => str(v),\n format: (v) => str(v).replace(/^https?:\\/\\/(www\\.)?/, \"\").replace(/\\/$/, \"\"),\n compare: (a, b) => collator.compare(str(a), str(b)),\n filters: TEXT_FILTERS,\n },\n rating: {\n label: \"Rating\",\n icon: \"star\",\n align: \"start\",\n width: 130,\n editor: \"rating\",\n empty: (v) => !v,\n parse: (t, col) => {\n const s = str(t).trim();\n if (!s) return { value: 0 };\n const stars = (s.match(/[★⭐*]/g) || []).length;\n const n = stars && stars === s.replace(/\\s/g, \"\").length ? stars : Number(s);\n const max = col?.max ?? 5;\n if (!Number.isInteger(n) || n < 0 || n > max) return { error: `Use a whole number from 0 to ${max}` };\n return { value: n };\n },\n toText: (v) => (v ? String(v) : \"\"),\n format: (v, col) => (v ? `${v} of ${col?.max ?? 5}` : \"\"),\n compare: (a, b) => (a || 0) - (b || 0),\n filters: [\n { id: \"gte\", label: \"is at least\", arg: \"number\", test: (v, a) => (v || 0) >= a },\n { id: \"lte\", label: \"is at most\", arg: \"number\", test: (v, a) => (v || 0) <= a },\n { id: \"eq\", label: \"is\", arg: \"number\", test: (v, a) => (v || 0) === a },\n ],\n },\n};\n\n// Every type also filters on emptiness.\nconst EMPTY_FILTERS = [\n { id: \"empty\", label: \"is empty\", arg: \"none\", test: (v, a, col, ctx, type) => type.empty(v) },\n { id: \"not-empty\", label: \"is not empty\", arg: \"none\", test: (v, a, col, ctx, type) => !type.empty(v) },\n];\n\n/** The type a column has: `custom` (the Spreadsheet's `types` prop)\n * first, then the built-ins; an unknown name reads as text. Missing\n * functions fall back to text's, so a custom type may be small. */\nexport function typeOf(col, custom) {\n const name = col?.type ?? \"text\";\n const key = custom ?? NONE;\n let byName = CACHE.get(key);\n if (!byName) CACHE.set(key, (byName = new Map()));\n let t = byName.get(name);\n if (t) return t;\n const own = custom?.[name] ? { ...(COLUMN_TYPES[name] ?? {}), ...custom[name] } : COLUMN_TYPES[name] ?? COLUMN_TYPES.text;\n t = { ...COLUMN_TYPES.text, empty: isBlank, ...own, name };\n t.allFilters = [...(own.filters ?? COLUMN_TYPES.text.filters), ...(name === \"checkbox\" ? [] : EMPTY_FILTERS)];\n byName.set(name, t);\n return t;\n}\n\n// One resolved type per (custom registry, name): typeOf runs per cell.\nconst NONE = {};\nconst CACHE = new WeakMap();\n\nexport { isBlank };\n\n/** Parse `text` for `col`; a blank cell is always fine (an empty value). */\nexport function parseCell(text, col, ctx, custom) {\n const t = typeOf(col, custom);\n try {\n return t.parse(text, col, ctx);\n } catch {\n return { error: \"That value does not fit here\" };\n }\n}\n\n/** A value moved to a column of another type: through its text. What\n * does not parse becomes empty. */\nexport function convertValue(value, from, to, ctx, custom) {\n const text = typeOf(from, custom).toText(value, from, ctx);\n const r = parseCell(text, to, ctx, custom);\n return \"error\" in r ? typeOf(to, custom).parse(\"\", to, ctx).value ?? null : r.value;\n}\n\n/** Guess a column type from sample text values (csv-import uses it):\n * checkbox, currency, number, date, url, select (few distinct values),\n * multi-select (comma lists of few distinct parts), else text. Most\n * values (four in five) must fit, so a few bad cells in an import are\n * flagged to fix instead of turning the whole column into text. */\nexport function guessType(values) {\n const vals = values.map((v) => str(v).trim()).filter(Boolean);\n if (vals.length === 0) return \"text\";\n const need = Math.max(1, Math.ceil(vals.length * 0.8));\n const most = (fn) => vals.filter(fn).length >= need;\n const isNum = (v) => {\n const n = parseNumberText(v);\n return n !== null && !Number.isNaN(n);\n };\n if (vals.every((v) => /^(true|false|yes|no|y|n|x|✓)$/i.test(v))) return \"checkbox\";\n if (most(isNum)) return vals.some((v) => /[$€£¥₹]|^[A-Z]{3}\\s?[\\d.,]/.test(v)) ? \"currency\" : \"number\";\n if (most((v) => /\\d/.test(v) && parseDateText(v) !== undefined)) return \"date\";\n if (most((v) => /^(https?:\\/\\/|www\\.)\\S+$/i.test(v))) return \"url\";\n const distinct = new Set(vals.map((v) => v.toLowerCase()));\n if (vals.some((v) => v.includes(\",\")) && vals.every((v) => v.length <= 80)) {\n const all = vals.flatMap((v) => v.split(/\\s*,\\s*/)).map((p) => p.toLowerCase());\n const parts = new Set(all);\n // Parts that repeat across cells (\"SaaS, B2B\" … \"B2B\"): tags, not prose.\n if (parts.size <= 12 && parts.size < all.length) return \"multi-select\";\n }\n if (vals.length >= 4 && distinct.size <= Math.min(12, Math.ceil(vals.length / 2)) && vals.every((v) => v.length <= 40)) return \"select\";\n return \"text\";\n}\n"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"path": "registry/blocks/spreadsheet/grid.js",
|
|
46
|
+
"type": "registry:component",
|
|
47
|
+
"target": "@components/makersclaw/blocks/spreadsheet/grid.js",
|
|
48
|
+
"content": "// The grid's pure helpers: the view (which columns show, in what order,\n// how wide, what is sorted and filtered), rows through that view, ranges,\n// the clipboard as TSV, paste and fill as lists of changes, undo. No\n// React: they run on the server and in a test.\nimport { isBlank, parseCell, typeOf } from \"./types\";\n\n// ── the view ────────────────────────────────────────────────────────────\n\n/** A view is plain JSON, the part of a table a person arranges and may\n * save (base-view keeps one per tab):\n * order column keys in the order they show (unknown keys are skipped;\n * columns missing from it follow, in `columns` order)\n * hidden keys not shown\n * widths { key: px }\n * frozen how many leading columns stay put while the rest scroll (1)\n * sort [{ key, dir: \"asc\" | \"desc\" }] — first wins, the rest break ties\n * filters [{ id, key, op, value }] — every one must hold */\nexport function emptyView() {\n return { order: [], hidden: [], widths: {}, frozen: 1, sort: [], filters: [] };\n}\n\nexport function normalizeView(view) {\n const v = view ?? {};\n return {\n order: Array.isArray(v.order) ? v.order : [],\n hidden: Array.isArray(v.hidden) ? v.hidden : [],\n widths: v.widths && typeof v.widths === \"object\" ? v.widths : {},\n frozen: Number.isInteger(v.frozen) ? Math.max(0, v.frozen) : 1,\n sort: Array.isArray(v.sort) ? v.sort : [],\n filters: Array.isArray(v.filters) ? v.filters : [],\n };\n}\n\n/** The columns in view order (hidden ones included, flagged). */\nexport function orderedColumns(columns, view) {\n const byKey = new Map(columns.map((c) => [c.key, c]));\n const seen = new Set();\n const out = [];\n for (const k of view.order) {\n const c = byKey.get(k);\n if (c && !seen.has(k)) {\n seen.add(k);\n out.push(c);\n }\n }\n for (const c of columns) if (!seen.has(c.key)) out.push(c);\n return out;\n}\n\n/** The columns that show, in order, each with its width. */\nexport function visibleColumns(columns, view, custom, narrow = false) {\n const hidden = new Set(view.hidden);\n return orderedColumns(columns, view)\n .filter((c) => !hidden.has(c.key))\n .map((c) => {\n const w = view.widths[c.key] ?? c.width ?? typeOf(c, custom).width ?? 160;\n return { ...c, width: Math.round(narrow ? Math.min(w, 220) : w) };\n });\n}\n\n// ── rows through the view ───────────────────────────────────────────────\n\n/** Does `row` pass every filter? A filter whose value is still blank (a\n * half-made condition) passes, so the rows do not vanish while typing. */\nexport function passesFilters(row, filters, columnsByKey, ctx, custom) {\n for (const f of filters) {\n const col = columnsByKey.get(f.key);\n if (!col) continue;\n const t = typeOf(col, custom);\n const op = t.allFilters.find((o) => o.id === f.op) ?? t.allFilters[0];\n if (!op) continue;\n if (op.arg !== \"none\" && isBlank(f.value)) continue;\n const v = row[col.key];\n // An empty cell never matches a value condition (\"> 100\", \"contains\").\n if (op.arg !== \"none\" && t.empty(v) && ![\"ne\", \"not-contains\", \"none\"].includes(op.id)) return false;\n if (!op.test(v, f.value, col, ctx, t)) return false;\n }\n return true;\n}\n\n/** Does any shown cell's text contain `query`? */\nexport function matchesSearch(row, query, cols, ctx, custom) {\n const q = query.trim().toLowerCase();\n if (!q) return true;\n return cols.some((c) => typeOf(c, custom).format(row[c.key], c, ctx).toLowerCase().includes(q));\n}\n\n/** Rows filtered, searched and sorted — a new array; the input is untouched. */\nexport function processRows(rows, columns, view, { search = \"\", ctx, custom } = {}) {\n const byKey = new Map(columns.map((c) => [c.key, c]));\n const shown = visibleColumns(columns, view, custom);\n let out = rows;\n if (view.filters.length) out = out.filter((r) => passesFilters(r, view.filters, byKey, ctx, custom));\n if (search.trim()) out = out.filter((r) => matchesSearch(r, search, shown, ctx, custom));\n const sorts = view.sort.map((s) => ({ ...s, col: byKey.get(s.key) })).filter((s) => s.col);\n if (sorts.length) {\n const typed = sorts.map((s) => ({ ...s, t: typeOf(s.col, custom), sign: s.dir === \"desc\" ? -1 : 1 }));\n // Stable: equal rows keep the order they came in.\n out = out\n .map((r, i) => [r, i])\n .sort(([a, ia], [b, ib]) => {\n for (const s of typed) {\n const va = a[s.key];\n const vb = b[s.key];\n const ea = s.t.empty(va);\n const eb = s.t.empty(vb);\n if (ea || eb) {\n if (ea && eb) continue;\n return ea ? 1 : -1; // empties last, whichever way\n }\n const d = s.t.compare(va, vb, s.col, ctx);\n if (d) return d * s.sign;\n }\n return ia - ib;\n })\n .map(([r]) => r);\n } else if (out === rows) out = rows.slice();\n return out;\n}\n\n// ── ranges ──────────────────────────────────────────────────────────────\n\n/** Two corners → { r0, r1, c0, c1 }, inclusive and ordered. */\nexport function rangeOf(a, b) {\n return { r0: Math.min(a.r, b.r), r1: Math.max(a.r, b.r), c0: Math.min(a.c, b.c), c1: Math.max(a.c, b.c) };\n}\n\nexport const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\n\nexport const inRange = (g, r, c) => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1;\n\n/** Where a key press moves the active cell: arrows by one, ⌘/Ctrl +\n * arrow to the edge, Home/End to the row's ends (with ⌘, the table's),\n * PageUp/Down by `page` rows. Returns null for any other key. */\nexport function moveBy(key, { r, c }, { rows, cols, page = 10, jump = false }) {\n const lastR = rows - 1;\n const lastC = cols - 1;\n switch (key) {\n case \"ArrowUp\":\n return { r: jump ? 0 : clamp(r - 1, 0, lastR), c };\n case \"ArrowDown\":\n return { r: jump ? lastR : clamp(r + 1, 0, lastR), c };\n case \"ArrowLeft\":\n return { r, c: jump ? 0 : clamp(c - 1, 0, lastC) };\n case \"ArrowRight\":\n return { r, c: jump ? lastC : clamp(c + 1, 0, lastC) };\n case \"Home\":\n return { r: jump ? 0 : r, c: 0 };\n case \"End\":\n return { r: jump ? lastR : r, c: lastC };\n case \"PageUp\":\n return { r: clamp(r - page, 0, lastR), c };\n case \"PageDown\":\n return { r: clamp(r + page, 0, lastR), c };\n default:\n return null;\n }\n}\n\n// ── the clipboard ───────────────────────────────────────────────────────\n\n/** Tab-separated text → rows of cells. Quoted cells (what Sheets and\n * Excel write when a cell holds a tab, a newline or a quote) are read\n * whole; a trailing newline adds no row. */\nexport function parseTSV(text) {\n const s = String(text ?? \"\").replace(/\\r\\n?/g, \"\\n\");\n const rows = [];\n let row = [];\n let cell = \"\";\n let i = 0;\n let quoted = false;\n let atStart = true;\n while (i < s.length) {\n const ch = s[i];\n if (quoted) {\n if (ch === '\"') {\n if (s[i + 1] === '\"') {\n cell += '\"';\n i += 2;\n continue;\n }\n quoted = false;\n i++;\n continue;\n }\n cell += ch;\n i++;\n continue;\n }\n if (ch === '\"' && atStart) {\n quoted = true;\n atStart = false;\n i++;\n continue;\n }\n if (ch === \"\\t\") {\n row.push(cell);\n cell = \"\";\n atStart = true;\n i++;\n continue;\n }\n if (ch === \"\\n\") {\n row.push(cell);\n rows.push(row);\n row = [];\n cell = \"\";\n atStart = true;\n i++;\n continue;\n }\n cell += ch;\n atStart = false;\n i++;\n }\n if (cell !== \"\" || row.length) {\n row.push(cell);\n rows.push(row);\n }\n return rows;\n}\n\n/** Rows of text → TSV, quoting a cell that holds a tab, newline or quote. */\nexport function toTSV(matrix) {\n return matrix\n .map((row) => row.map((v) => (/[\\t\\n\"]/.test(v) ? `\"${String(v).replace(/\"/g, '\"\"')}\"` : String(v ?? \"\"))).join(\"\\t\"))\n .join(\"\\n\");\n}\n\n/** The range's cells as text rows (what a copy puts on the clipboard). */\nexport function rangeText(range, rows, cols, ctx, custom) {\n const out = [];\n for (let r = range.r0; r <= range.r1; r++) {\n const row = rows[r];\n const line = [];\n for (let c = range.c0; c <= range.c1; c++) {\n const col = cols[c];\n line.push(row && col ? typeOf(col, custom).toText(row[col.key], col, ctx) : \"\");\n }\n out.push(line);\n }\n return out;\n}\n\n// ── changes ─────────────────────────────────────────────────────────────\n// A change is { rowId, key, value, prev }. A batch of them is one undo step.\n\nconst same = (a, b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => x === b[i])) || (isBlank(a) && isBlank(b) && !(a === false || b === false));\n\n/** Keep only changes that change something. */\nexport function realChanges(changes) {\n return changes.filter((ch) => !same(ch.value, ch.prev));\n}\n\n/** `rows` with `changes` laid on — a new array; untouched rows keep their identity. */\nexport function applyChanges(rows, changes, field = \"value\") {\n if (!changes.length) return rows;\n const byRow = new Map();\n for (const ch of changes) {\n if (!byRow.has(ch.rowId)) byRow.set(ch.rowId, {});\n byRow.get(ch.rowId)[ch.key] = ch[field];\n }\n return rows.map((r) => (byRow.has(r.id) ? { ...r, ...byRow.get(r.id) } : r));\n}\n\n/** The changes that undo `changes`. */\nexport function invertChanges(changes) {\n return changes.map((ch) => ({ ...ch, value: ch.prev, prev: ch.value })).reverse();\n}\n\n/**\n * Text rows pasted with their top-left at (r, c) → what to change. One\n * value pasted onto a bigger range fills the range (as Sheets does);\n * a block that runs past the last row lists the rest in `extra` (text\n * rows the caller may add as new rows). Read-only columns are skipped;\n * text that does not parse is reported in `errors` and skipped.\n */\nexport function pasteChanges(matrix, range, rows, cols, ctx, custom) {\n const changes = [];\n const errors = [];\n const creates = new Map(); // col key → new options\n if (!matrix.length) return { changes, errors, creates, extra: [] };\n const single = matrix.length === 1 && matrix[0].length === 1;\n const height = single ? range.r1 - range.r0 + 1 : matrix.length;\n const width = single ? range.c1 - range.c0 + 1 : Math.max(...matrix.map((l) => l.length));\n const extra = [];\n for (let i = 0; i < height; i++) {\n const r = range.r0 + i;\n const line = single ? matrix[0] : matrix[i];\n if (r >= rows.length) {\n extra.push(line);\n continue;\n }\n const row = rows[r];\n for (let j = 0; j < width; j++) {\n const col = cols[range.c0 + j];\n if (!col || col.readOnly || col.computed) continue;\n const text = single ? line[0] : line[j] ?? \"\";\n const withNew = { ...col, options: [...(col.options ?? []), ...(creates.get(col.key) ?? [])] };\n const res = parseCell(text, withNew, ctx, custom);\n if (\"error\" in res) {\n errors.push({ rowId: row.id, key: col.key, text, error: res.error });\n continue;\n }\n if (res.create) creates.set(col.key, [...(creates.get(col.key) ?? []), ...res.create]);\n changes.push({ rowId: row.id, key: col.key, value: res.value, prev: row[col.key] });\n }\n }\n return { changes: realChanges(changes), errors, creates, extra };\n}\n\n/**\n * Fill from the `range` down (or up) to row `to`: the range's rows repeat.\n * A single column of two or more numbers that step evenly continues the\n * series (1, 2, 3 → 4, 5…), as a spreadsheet does.\n */\nexport function fillChanges(range, to, rows, cols) {\n const changes = [];\n const h = range.r1 - range.r0 + 1;\n const down = to > range.r1;\n const targets = [];\n if (down) for (let r = range.r1 + 1; r <= Math.min(to, rows.length - 1); r++) targets.push(r);\n else for (let r = range.r0 - 1; r >= Math.max(to, 0); r--) targets.push(r);\n for (let c = range.c0; c <= range.c1; c++) {\n const col = cols[c];\n if (!col || col.readOnly || col.computed) continue;\n const src = [];\n for (let r = range.r0; r <= range.r1; r++) src.push(rows[r]?.[col.key]);\n let step = null;\n if (h >= 2 && src.every((v) => typeof v === \"number\")) {\n const d = src[1] - src[0];\n if (src.every((v, i) => i === 0 || Math.abs(v - src[i - 1] - d) < 1e-9)) step = d;\n }\n targets.forEach((r, k) => {\n const row = rows[r];\n if (!row) return;\n let value;\n if (step !== null) value = down ? src[h - 1] + step * (k + 1) : src[0] - step * (k + 1);\n else value = down ? src[k % h] : src[h - 1 - (k % h)];\n if (Array.isArray(value)) value = value.slice();\n changes.push({ rowId: row.id, key: col.key, value, prev: row[col.key] });\n });\n }\n return realChanges(changes);\n}\n\n/** Clearing a range: each cell to its type's empty. */\nexport function clearChanges(range, rows, cols, ctx, custom) {\n const changes = [];\n for (let r = range.r0; r <= range.r1; r++) {\n const row = rows[r];\n if (!row) continue;\n for (let c = range.c0; c <= range.c1; c++) {\n const col = cols[c];\n if (!col || col.readOnly || col.computed) continue;\n const empty = parseCell(\"\", col, ctx, custom);\n changes.push({ rowId: row.id, key: col.key, value: \"error\" in empty ? null : empty.value, prev: row[col.key] });\n }\n }\n return realChanges(changes);\n}\n\n/** A number summary of the range's numeric cells, for the status bar. */\nexport function summarize(range, rows, cols) {\n let count = 0;\n let filled = 0;\n let sum = 0;\n let nums = 0;\n let numCol = null;\n for (let r = range.r0; r <= range.r1; r++) {\n const row = rows[r];\n if (!row) continue;\n for (let c = range.c0; c <= range.c1; c++) {\n const col = cols[c];\n if (!col) continue;\n count++;\n const v = row[col.key];\n if (!isBlank(v) && v !== false) filled++;\n if (typeof v === \"number\" && (col.type === \"number\" || col.type === \"currency\")) {\n sum += v;\n nums++;\n numCol = numCol === null || numCol?.key === col.key ? col : numCol && numCol.type === col.type ? numCol : false;\n }\n }\n }\n return { count, filled, sum, nums, avg: nums ? sum / nums : 0, numCol: numCol || null };\n}\n"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "registry/blocks/spreadsheet/README.md",
|
|
52
|
+
"type": "registry:file",
|
|
53
|
+
"target": "@components/makersclaw/blocks/spreadsheet/README.md",
|
|
54
|
+
"content": "# Spreadsheet\n\nA table you edit like Google Sheets or Airtable. Click a cell and type;\nEnter edits, Tab and the arrows move, Shift extends a range, ⌘C / ⌘V copy\nand paste ranges to and from Sheets and Excel, drag the small square at a\nrange's corner to fill down, ⌘Z undoes. Every column has a type — text,\nnumber, currency, date, single select, multiple select, person, checkbox,\nlink, rating — and each type draws its own cell and opens its own editor\n(a calendar for a date, a searchable list for a select or a person). Drag\na header to reorder, its edge to resize; its menu sorts, filters, renames,\nchanges the type, inserts, freezes, hides and deletes. The toolbar has\nsearch, a filter panel, a sort panel, show/hide fields and \"Add row\"; the\nline under the grid counts rows and sums the selected numbers.\n\nIt is virtualised in both directions (`@tanstack/react-virtual`): only the\ncells in the window are drawn, so 10,000 rows scroll like ten. On a phone\nthe rows grow to 44px, the frozen column narrows, a tap selects and a\nsecond tap edits; long-press opens the row menu.\n\n```jsx\nimport { Spreadsheet } from \"../components/blocks/spreadsheet\"; // a Makersclaw app (\"./blocks/spreadsheet\" from components/)\nimport { Spreadsheet } from \"@/components/makersclaw/blocks/spreadsheet\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4). `makersclaw add\nspreadsheet` adds `@tanstack/react-virtual` (pinned, exact) to\npackage.json: run `npm install` after. The helpers (the type registry,\n`processRows`, TSV) are plain functions and run on the server too.\nBlocks built on this one (base-view, csv-import) import it, never the\nvirtualiser.\n\n## The data\n\n`rows` — plain objects with a unique `id`; each column reads one key.\n`columns` — what each key is:\n\n```js\nconst COLUMNS = [\n { key: \"company\", name: \"Company\", type: \"text\", width: 200, required: true },\n { key: \"stage\", name: \"Stage\", type: \"select\",\n options: [{ value: \"lead\", label: \"Lead\", hue: 5 }, { value: \"won\", label: \"Won\", hue: 1 }] },\n { key: \"value\", name: \"Deal value\", type: \"currency\", currency: \"USD\", precision: 0 },\n { key: \"owner\", name: \"Owner\", type: \"person\" }, // ids from the `people` prop\n { key: \"close\", name: \"Close date\", type: \"date\" }, // \"YYYY-MM-DD\"\n { key: \"segments\", name: \"Segments\", type: \"multi-select\", options: [...] },\n { key: \"fit\", name: \"Fit\", type: \"rating\", max: 5 },\n { key: \"contacted\", name: \"Contacted\", type: \"checkbox\" },\n { key: \"website\", name: \"Website\", type: \"url\" },\n];\n```\n\nA column: `key`, `name`, `type` (default `\"text\"`), and any of `width`\n(px), `readOnly`, `required` (an empty cell is marked as a problem),\n`options` (select types: `{ value, label, hue: 1–5 }` — the hue is a\nchart token, never a colour), `allowNew: false` (a select that refuses\nnew options), `currency` + `precision` (currency, number), `max`\n(rating, default 5), `multiple: true` (person: several people),\n`dateOrder: \"dmy\"` (how `3/4/2026` is read; default month first). Any\nother key is kept and ignored, so a block on top can carry its own.\n\nWhat each type stores:\n\n| type | value |\n| --- | --- |\n| `text`, `url` | a string |\n| `number`, `currency` | a number, or `null` |\n| `date` | `\"YYYY-MM-DD\"`, or `null` — a day, never a `Date` |\n| `select` | an option's `value`, or `null` |\n| `multi-select` | an array of option values |\n| `person` | a person's `id` (an array with `multiple`) |\n| `checkbox` | `true` / `false` |\n| `rating` | a whole number, `0` = none |\n\n## Props\n\nRequired first; the rest have the defaults shown.\n\n- `rows` — the records, as above.\n- `columns` — the fields, as above.\n\nSaving. Every callback is async and answers `{ ok: true }` or\n`{ ok: false, error }` (a throw counts as a failure; nothing returned\ncounts as ok). The grid applies the change at once and, on a failure,\nputs it back and says so in a toast.\n\n- `onChange(changes)` — cell edits, always a batch: one typed value, a\n paste, a fill, a clear, an undo. `changes` is\n `[{ rowId, key, value, prev }]`. Without it the grid still edits (in\n memory).\n- `onAddRows(rows, { index, restore })` — new rows (from \"Add row\", the\n row menu's insert, a paste that runs past the end, or an Undo of a\n delete when `restore` is true). `index` is where in `rows` they go\n (absent: the end). A row's `id` is made here (`createRow` or a random\n one); answer `{ ok: true, rows: [{ id }] }` to swap in the server's ids.\n Without it there is no Add row.\n- `onDeleteRows(ids)` — rows deleted from the row menu or the selection\n line; a toast offers Undo (which calls `onAddRows` with `restore`).\n Without it rows cannot be deleted.\n- `onColumnsChange(columns, { reason })` — the whole new column list after\n a rename, a type change, an added, deleted or restored field, or a\n select option created while typing; `reason` is `\"rename\"`, `\"type\"`,\n `\"add\"`, `\"delete\"`, `\"restore\"` or `\"options\"`. A type change also sends\n the converted values through `onChange`. Without it the header menu\n cannot change fields (options typed into a select are kept in memory).\n- `createRow()` — a new row's starting values (`{ id, stage: \"lead\" }`).\n Give each call a unique `id`.\n\nThe view — which fields show, in what order and width, what is frozen,\nsorted and filtered. Plain JSON; store it per view (base-view keeps one\nper tab):\n\n```js\n{ order: [\"company\", \"stage\", …], hidden: [\"website\"], widths: { company: 240 },\n frozen: 1, sort: [{ key: \"value\", dir: \"desc\" }],\n filters: [{ id: \"f1\", key: \"stage\", op: \"any\", value: [\"won\", \"negotiation\"] }] }\n```\n\n- `view` + `onViewChange(view)` — controlled; or `defaultView` and the\n grid keeps it (still reporting through `onViewChange`). Keep it in state:\n a new `sort` or `filters` array each render re-sorts each render.\n- A filter's `op` is one of its type's conditions (`COLUMN_TYPES[type]\n .filters[].id`, plus `empty` / `not-empty`); a blank `value` means \"not\n set yet\" and passes every row.\n- Edits do not re-sort: an edited row stays where it is (as in Airtable)\n and the line under the grid offers \"Sort again\". New data from the\n props re-runs the view.\n\nBehaviour and look:\n\n- `people` — `[{ id, name, email?, src? }]`, who a `person` cell can hold.\n- `types` — your own column types, or overrides of the built-ins (see\n below). Keep the object stable (a module constant).\n- `validate(row, column)` — a message when a cell is wrong, else null. A\n wrong cell gets a red corner and its message on hover and under the\n grid; the line under the grid counts problems and jumps to the next.\n- `readOnly` — nothing edits (select, copy, sort, filter still work).\n Per column: `column.readOnly`.\n- `lockFields` — the header menu cannot rename, retype, add or delete\n fields, though `onColumnsChange` is given; select options typed into a\n cell still arrive through it (reason `\"options\"`). csv-import's review\n uses this.\n- `onExpandRow(row)` — adds an open button to each row's number (and\n Space / ⌘↵ / the row menu); draw the record yourself (a Sheet).\n- `onSelectionChange({ active: { rowId, key }, rowIds, keys } | null)`.\n- `loading` — skeleton rows.\n- `label` — what a screen reader calls the grid (`\"Table\"`).\n- `noun` — what a row is (`\"deal\"`): \"Add deal\", \"10,000 deals\". Plural\n is noun + \"s\". Default `\"row\"`.\n- `height` — the grid's height (`560`, or any CSS length); it scrolls\n inside it.\n- `toolbar` — `false` hides the toolbar (a parent draws its own and\n drives the grid through the handle). `toolbarStart`, `toolbarEnd` —\n nodes at the toolbar's ends (an \"Import CSV\" button).\n- `empty` — a node for the empty table, instead of \"No rows yet\".\n- `locale`, `currency` — for numbers, amounts and dates (the reader's\n locale, `\"USD\"`). `today` — `\"YYYY-MM-DD\"` for the date picker (a demo\n that must not change day to day).\n- `ref` — the handle, below.\n\n### The handle\n\n`<Spreadsheet ref={grid} …>` then `grid.current`:\n\n- `focus()` · `clearSelection()`\n- `select({ rowId, key, toRowId?, toKey? })` → true when found; a range\n when `to…` is given\n- `getSelection()` → `{ active, rowIds, keys }` or null\n- `scrollToRow(rowId)` · `edit(rowId, key)` — opens that cell's editor\n- `addRow(values?)` — adds, selects and edits a row (through `onAddRows`)\n- `deleteRows(ids)` · `undo()` · `redo()`\n- `copy()` → the selection as TSV · `paste(text)` — TSV at the selection\n- `visibleRows()` · `visibleColumns()` — after filter and sort, in order\n- `resort()` — the \"Sort again\" button\n\n### The column-type registry\n\n`COLUMN_TYPES` holds the built-ins; `typeOf(column, types)` resolves one\n(your `types` first, then the built-ins, unknown names read as text). A\ntype is plain data and functions — the logic runs on the server too:\n\n```js\n{\n label: \"Currency\", icon: \"dollar\", // a kit glyph for the header\n align: \"end\", width: 130, // default width, px\n editor: \"number\", // the in-cell editor: text · number · date ·\n // select · person · toggle · rating · url · none\n multiple: false, // select editor: pick several\n empty: (v) => v == null, // default: null, \"\" or []\n parse: (text, column, ctx) => ({ value }) | ({ error }), // typed or pasted text in;\n // a select may add `create: [{ value, label, hue }]`\n toText: (v, column, ctx) => \"1250.5\", // what a copy puts on the clipboard, and an edit starts from\n format: (v, column, ctx) => \"$1,250.50\",// what the cell shows (and search matches)\n compare: (a, b, column, ctx) => a - b, // sort order; empties always sort last\n filters: [{ id: \"gt\", label: \">\", arg: \"number\", test: (v, arg, column, ctx) => v > arg }],\n // arg: text · number · date · options · person · none\n render: (v, column, ctx) => <node/>, // optional: draw the cell yourself\n Editor: MyEditor, // optional: your own editor component\n}\n```\n\n`ctx` is `{ people, locale, currency }`. A custom type may be small —\nanything it leaves out comes from `text`:\n\n```js\nconst TYPES = {\n percent: {\n label: \"Percent\", icon: \"hash\", align: \"end\", width: 100, editor: \"number\",\n parse: (t) => { const n = parseNumberText(String(t).replace(\"%\", \"\")); return Number.isNaN(n) ? { error: \"Not a percent\" } : { value: n == null ? null : n / 100 }; },\n toText: (v) => (v == null ? \"\" : String(v * 100)),\n format: (v) => (v == null ? \"\" : `${Math.round(v * 100)}%`),\n compare: (a, b) => a - b,\n },\n};\n```\n\nA custom `Editor` gets `{ value, column, type, ctx, initial, today,\ncanCreate, onCommit(value, move, { create }?), onCancel() }` and draws\ninside the cell (absolutely positioned over it). `initial` is the key\nthat opened it (a typed letter) or null; `move` is `\"down\"`, `\"up\"`,\n`\"right\"`, `\"left\"` or null (stay). Float a panel beside the cell with\nthe exported `CellPanel` (`anchorRef`, `onDismiss`, `width`, `label`).\n\nOther exports, for blocks on top: `CellValue` (a cell's display),\n`OptionBadge`, `editorFor(type)`, `parseCell`, `convertValue(v, from,\nto)`, `guessType(texts)` (csv-import's guess), `parseNumberText`,\n`parseDateText`, `optionValue(label, taken)`, `emptyView`,\n`normalizeView`, `orderedColumns`, `visibleColumns`, `processRows(rows,\ncolumns, view, { search, ctx, custom })`, `passesFilters`, `parseTSV`,\n`toTSV`, `pasteChanges`, `fillChanges`, `clearChanges`, `applyChanges`,\n`invertChanges`, `TYPE_ORDER`, `sortWords(type)`.\n\n## Keys\n\n↑ ↓ ← → move (⌘ to the edge; Shift extends) · Tab / Shift+Tab · Home /\nEnd · Page Up / Down · Enter or F2 edits · typing replaces · Esc cancels ·\nDelete clears · ⌘C ⌘X ⌘V · ⌘D fills down · ⌘Z / ⇧⌘Z · ⌘A all · Shift+Space\nthe row · ⌘Space the column · Space ticks a box or opens the row ·\nShift+F10 the row menu.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow, insertRow, deleteRow } from \"../lib/actions\";\nimport { Spreadsheet } from \"./blocks/spreadsheet\";\n\nconst COLUMNS = [\n { key: \"name\", name: \"Name\", type: \"text\", required: true },\n { key: \"stage\", name: \"Stage\", type: \"select\", options: [{ value: \"lead\", label: \"Lead\", hue: 2 }, { value: \"won\", label: \"Won\", hue: 1 }] },\n { key: \"amount\", name: \"Amount\", type: \"currency\", precision: 0 },\n { key: \"close\", name: \"Close date\", type: \"date\" },\n];\n\nexport default function Deals({ deals, team }) {\n const [view, setView] = useState({ sort: [{ key: \"amount\", dir: \"desc\" }] });\n return (\n <Spreadsheet\n label=\"Deals\"\n noun=\"deal\"\n rows={deals}\n columns={COLUMNS}\n people={team}\n view={view}\n onViewChange={setView}\n onChange={async (changes) => {\n for (const c of changes) {\n const r = await updateRow({ source: \"public.deals\", id: c.rowId, patch: { [c.key]: c.value } });\n if (!r.ok) return r;\n }\n return { ok: true };\n }}\n onAddRows={async (rows) => {\n const made = [];\n for (const { id, ...values } of rows) {\n const r = await insertRow({ source: \"public.deals\", values: { stage: \"lead\", ...values } });\n if (!r.ok) return r;\n made.push({ id: r.id }); // the database's id replaces the one the grid made up\n }\n return { ok: true, rows: made };\n }}\n onDeleteRows={async (ids) => {\n for (const id of ids) {\n const r = await deleteRow({ source: \"public.deals\", id });\n if (!r.ok) return r;\n }\n return { ok: true };\n }}\n />\n );\n}\n```\n\n## What it will not do\n\n- No formulas. A value is what was typed; a derived column is computed by\n you and passed in (mark it `readOnly`).\n- No merged cells, no rich text in a cell, no row heights of their own:\n every row is one height (that is what keeps 10,000 rows smooth).\n- No grouping of rows under headings — that is a view on top (base-view).\n- Search and filters run in the browser over `rows`. For a table too big\n to send, filter on the server and pass the page.\n- Sort and filter follow the view, not the edits: an edited row stays put\n until \"Sort again\" (or new props).\n- Paste reads plain text (TSV). Pasted images and HTML tables are read as\n their text.\n"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"categories": [
|
|
58
|
+
"spreadsheet",
|
|
59
|
+
"grid",
|
|
60
|
+
"table",
|
|
61
|
+
"airtable",
|
|
62
|
+
"google sheets",
|
|
63
|
+
"excel",
|
|
64
|
+
"editable",
|
|
65
|
+
"cells",
|
|
66
|
+
"inline edit",
|
|
67
|
+
"virtualized",
|
|
68
|
+
"10k rows",
|
|
69
|
+
"copy paste",
|
|
70
|
+
"tsv",
|
|
71
|
+
"fill down",
|
|
72
|
+
"columns",
|
|
73
|
+
"sort",
|
|
74
|
+
"filter",
|
|
75
|
+
"select",
|
|
76
|
+
"multi select",
|
|
77
|
+
"person",
|
|
78
|
+
"rating",
|
|
79
|
+
"undo",
|
|
80
|
+
"keyboard",
|
|
81
|
+
"base"
|
|
82
|
+
],
|
|
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
|
+
"meta": {
|
|
85
|
+
"makersclaw": {
|
|
86
|
+
"version": "0.3.1",
|
|
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
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|