@twoux/ui 0.0.0-stage → 0.1.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.
Files changed (325) hide show
  1. package/LICENSE.md +61 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/NOTICE.md +64 -0
  4. package/README.md +211 -2
  5. package/SHADCN-DIFF.md +228 -0
  6. package/agent/AGENTS.md +358 -0
  7. package/agent/docs-md/accessibility.md +49 -0
  8. package/agent/docs-md/action-catalogue.md +81 -0
  9. package/agent/docs-md/actions.md +74 -0
  10. package/agent/docs-md/agent-activity-log.md +70 -0
  11. package/agent/docs-md/agent-permissions.md +82 -0
  12. package/agent/docs-md/agent-run.md +90 -0
  13. package/agent/docs-md/agents-on-behalf.md +74 -0
  14. package/agent/docs-md/ai-column.md +85 -0
  15. package/agent/docs-md/ai-origin-label.md +70 -0
  16. package/agent/docs-md/announcing-features.md +73 -0
  17. package/agent/docs-md/answer-actions.md +81 -0
  18. package/agent/docs-md/app-shell.md +114 -0
  19. package/agent/docs-md/artifact-card.md +89 -0
  20. package/agent/docs-md/ask-on-screen.md +72 -0
  21. package/agent/docs-md/assistant-page.md +100 -0
  22. package/agent/docs-md/assistant-panel.md +142 -0
  23. package/agent/docs-md/avatar.md +82 -0
  24. package/agent/docs-md/banner.md +106 -0
  25. package/agent/docs-md/board.md +163 -0
  26. package/agent/docs-md/breadcrumb.md +89 -0
  27. package/agent/docs-md/breakdown-table.md +97 -0
  28. package/agent/docs-md/bulk-action-bar.md +112 -0
  29. package/agent/docs-md/button.md +293 -0
  30. package/agent/docs-md/card.md +115 -0
  31. package/agent/docs-md/change-list.md +99 -0
  32. package/agent/docs-md/change-request.md +166 -0
  33. package/agent/docs-md/chart-area.md +87 -0
  34. package/agent/docs-md/chart-bar.md +100 -0
  35. package/agent/docs-md/chart-box-plot.md +88 -0
  36. package/agent/docs-md/chart-bullet.md +89 -0
  37. package/agent/docs-md/chart-candlestick.md +112 -0
  38. package/agent/docs-md/chart-card-line.md +120 -0
  39. package/agent/docs-md/chart-donut.md +90 -0
  40. package/agent/docs-md/chart-drill-down.md +72 -0
  41. package/agent/docs-md/chart-funnel.md +71 -0
  42. package/agent/docs-md/chart-heatmap.md +76 -0
  43. package/agent/docs-md/chart-histogram.md +81 -0
  44. package/agent/docs-md/chart-map.md +165 -0
  45. package/agent/docs-md/chart-mixed.md +88 -0
  46. package/agent/docs-md/chart-price.md +92 -0
  47. package/agent/docs-md/chart-radial-bars.md +109 -0
  48. package/agent/docs-md/chart-radial-lines.md +111 -0
  49. package/agent/docs-md/chart-sankey.md +104 -0
  50. package/agent/docs-md/chart-scatter.md +87 -0
  51. package/agent/docs-md/chart-share-bar.md +89 -0
  52. package/agent/docs-md/chart-treemap.md +98 -0
  53. package/agent/docs-md/chart-waterfall.md +93 -0
  54. package/agent/docs-md/checkbox.md +131 -0
  55. package/agent/docs-md/choosing-selection-control.md +71 -0
  56. package/agent/docs-md/citations-sources.md +79 -0
  57. package/agent/docs-md/citing-sources.md +63 -0
  58. package/agent/docs-md/clarify-before-acting.md +69 -0
  59. package/agent/docs-md/clarifying-question.md +92 -0
  60. package/agent/docs-md/code-block.md +174 -0
  61. package/agent/docs-md/colours.md +87 -0
  62. package/agent/docs-md/columns-menu.md +140 -0
  63. package/agent/docs-md/combobox.md +161 -0
  64. package/agent/docs-md/command-palette.md +126 -0
  65. package/agent/docs-md/confidence-note.md +87 -0
  66. package/agent/docs-md/configurable-dashboard.md +76 -0
  67. package/agent/docs-md/confirmation-dialog.md +93 -0
  68. package/agent/docs-md/connected-tools.md +80 -0
  69. package/agent/docs-md/connector-picker.md +127 -0
  70. package/agent/docs-md/consistency.md +53 -0
  71. package/agent/docs-md/content-density.md +61 -0
  72. package/agent/docs-md/conversation-history.md +78 -0
  73. package/agent/docs-md/create-record.md +80 -0
  74. package/agent/docs-md/csv-import.md +88 -0
  75. package/agent/docs-md/dashboard-items.md +78 -0
  76. package/agent/docs-md/data-export.md +61 -0
  77. package/agent/docs-md/data-grid.md +137 -0
  78. package/agent/docs-md/data-vis-colours.md +76 -0
  79. package/agent/docs-md/data-visualisation.md +76 -0
  80. package/agent/docs-md/date-picker.md +178 -0
  81. package/agent/docs-md/delete-record.md +75 -0
  82. package/agent/docs-md/dense-table.md +495 -0
  83. package/agent/docs-md/design-tokens.md +53 -0
  84. package/agent/docs-md/disabled-read-only.md +75 -0
  85. package/agent/docs-md/drag-and-drop.md +74 -0
  86. package/agent/docs-md/dropdown-menu.md +137 -0
  87. package/agent/docs-md/edit-record.md +73 -0
  88. package/agent/docs-md/empty-loading-error.md +78 -0
  89. package/agent/docs-md/empty-state.md +128 -0
  90. package/agent/docs-md/error-messages.md +75 -0
  91. package/agent/docs-md/error-state.md +123 -0
  92. package/agent/docs-md/export-menu.md +86 -0
  93. package/agent/docs-md/feedback-prompt.md +88 -0
  94. package/agent/docs-md/field-mapping.md +78 -0
  95. package/agent/docs-md/file-drop.md +110 -0
  96. package/agent/docs-md/filter-bar.md +138 -0
  97. package/agent/docs-md/filter-panel.md +103 -0
  98. package/agent/docs-md/filtering-search.md +79 -0
  99. package/agent/docs-md/follow-ups-starters.md +79 -0
  100. package/agent/docs-md/form-validation.md +95 -0
  101. package/agent/docs-md/generated-columns.md +65 -0
  102. package/agent/docs-md/generated-drafts-files.md +71 -0
  103. package/agent/docs-md/grouped-list.md +89 -0
  104. package/agent/docs-md/guide-cue.md +170 -0
  105. package/agent/docs-md/help-panel.md +103 -0
  106. package/agent/docs-md/help-system.md +74 -0
  107. package/agent/docs-md/iconography.md +49 -0
  108. package/agent/docs-md/inbox.md +108 -0
  109. package/agent/docs-md/inline-editing-validation.md +80 -0
  110. package/agent/docs-md/inline-input-in-answer.md +70 -0
  111. package/agent/docs-md/items-palette.md +116 -0
  112. package/agent/docs-md/keyboard-model.md +47 -0
  113. package/agent/docs-md/keyboard-navigation.md +68 -0
  114. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  115. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  116. package/agent/docs-md/labelling-ai-output.md +66 -0
  117. package/agent/docs-md/layout.md +47 -0
  118. package/agent/docs-md/list-table.md +254 -0
  119. package/agent/docs-md/loading-skeleton.md +91 -0
  120. package/agent/docs-md/managing-agents.md +71 -0
  121. package/agent/docs-md/message-composer.md +105 -0
  122. package/agent/docs-md/migrating.md +49 -0
  123. package/agent/docs-md/motion.md +93 -0
  124. package/agent/docs-md/navigation.md +76 -0
  125. package/agent/docs-md/notification-channels.md +65 -0
  126. package/agent/docs-md/notifications-decision.md +100 -0
  127. package/agent/docs-md/onboarding-step.md +114 -0
  128. package/agent/docs-md/order-book.md +108 -0
  129. package/agent/docs-md/page-header.md +96 -0
  130. package/agent/docs-md/pagination.md +88 -0
  131. package/agent/docs-md/pane-layout.md +105 -0
  132. package/agent/docs-md/period-comparison.md +66 -0
  133. package/agent/docs-md/plan-card.md +120 -0
  134. package/agent/docs-md/popover-rules.md +88 -0
  135. package/agent/docs-md/prompt-editor.md +90 -0
  136. package/agent/docs-md/propose-apply.md +65 -0
  137. package/agent/docs-md/provider-logo.md +97 -0
  138. package/agent/docs-md/radio.md +83 -0
  139. package/agent/docs-md/ranked-list.md +102 -0
  140. package/agent/docs-md/reasoning-progress.md +72 -0
  141. package/agent/docs-md/record-detail.md +71 -0
  142. package/agent/docs-md/regenerating-answers.md +70 -0
  143. package/agent/docs-md/run-status-bar.md +94 -0
  144. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  145. package/agent/docs-md/saved-views.md +149 -0
  146. package/agent/docs-md/saving-views.md +68 -0
  147. package/agent/docs-md/scheduler.md +258 -0
  148. package/agent/docs-md/secondary-panels.md +72 -0
  149. package/agent/docs-md/segmented-control.md +88 -0
  150. package/agent/docs-md/selection-bulk-actions.md +70 -0
  151. package/agent/docs-md/separator.md +75 -0
  152. package/agent/docs-md/setup-checklist.md +92 -0
  153. package/agent/docs-md/shape-elevation.md +55 -0
  154. package/agent/docs-md/sheet-grid.md +211 -0
  155. package/agent/docs-md/side-panel.md +134 -0
  156. package/agent/docs-md/sign-in.md +114 -0
  157. package/agent/docs-md/spacing.md +55 -0
  158. package/agent/docs-md/split-button.md +100 -0
  159. package/agent/docs-md/stat-compare.md +82 -0
  160. package/agent/docs-md/static-dashboard.md +75 -0
  161. package/agent/docs-md/status-badge.md +140 -0
  162. package/agent/docs-md/step-card.md +75 -0
  163. package/agent/docs-md/stepper.md +92 -0
  164. package/agent/docs-md/suggested-changes.md +91 -0
  165. package/agent/docs-md/switch.md +78 -0
  166. package/agent/docs-md/table-settings.md +81 -0
  167. package/agent/docs-md/tabs.md +85 -0
  168. package/agent/docs-md/tag-input.md +108 -0
  169. package/agent/docs-md/tag.md +79 -0
  170. package/agent/docs-md/task-builder.md +87 -0
  171. package/agent/docs-md/team-instructions.md +72 -0
  172. package/agent/docs-md/templates-gallery.md +85 -0
  173. package/agent/docs-md/text-input.md +160 -0
  174. package/agent/docs-md/thinking-steps.md +89 -0
  175. package/agent/docs-md/timeline.md +189 -0
  176. package/agent/docs-md/timestamp.md +84 -0
  177. package/agent/docs-md/timestamps.md +70 -0
  178. package/agent/docs-md/toast.md +117 -0
  179. package/agent/docs-md/tooltip.md +90 -0
  180. package/agent/docs-md/typography.md +62 -0
  181. package/agent/docs-md/undo-destructive.md +66 -0
  182. package/agent/docs-md/unsaved-changes.md +71 -0
  183. package/agent/docs-md/usage-meter.md +80 -0
  184. package/agent/docs-md/user-feedback.md +71 -0
  185. package/agent/docs-md/voice-and-tone.md +60 -0
  186. package/agent/docs-md/why-choice.md +93 -0
  187. package/agent/docs-md/workspace-onboarding.md +74 -0
  188. package/agent/docs-md/writing.md +48 -0
  189. package/agent/llms-full.txt +18369 -0
  190. package/agent/llms.txt +198 -0
  191. package/agent/skills/twoux-audit/SKILL.md +49 -0
  192. package/agent/skills/twoux-build/SKILL.md +39 -0
  193. package/agent/skills/twoux-design/SKILL.md +54 -0
  194. package/agent/skills/twoux-write/SKILL.md +32 -0
  195. package/bin/check.mjs +123 -0
  196. package/bin/docs.mjs +24 -0
  197. package/bin/guard.mjs +27 -0
  198. package/bin/init.mjs +113 -0
  199. package/bin/knowledge.mjs +87 -0
  200. package/bin/mcp.mjs +116 -0
  201. package/bin/theme.mjs +168 -0
  202. package/bin/twoux.mjs +25 -0
  203. package/package.json +80 -3
  204. package/registry/jobs.json +1028 -0
  205. package/registry/palette.json +770 -0
  206. package/registry/rules.json +913 -0
  207. package/src/components/agent-review.tsx +364 -0
  208. package/src/components/ai-basics.tsx +212 -0
  209. package/src/components/ai-work.tsx +304 -0
  210. package/src/components/ai.tsx +227 -0
  211. package/src/components/alert-dialog.tsx +203 -0
  212. package/src/components/alert.tsx +86 -0
  213. package/src/components/avatar.tsx +114 -0
  214. package/src/components/badge.tsx +59 -0
  215. package/src/components/board.tsx +397 -0
  216. package/src/components/breadcrumb.tsx +114 -0
  217. package/src/components/button-group.tsx +90 -0
  218. package/src/components/button.tsx +109 -0
  219. package/src/components/calendar.tsx +247 -0
  220. package/src/components/card.tsx +102 -0
  221. package/src/components/chart-map.tsx +336 -0
  222. package/src/components/charts-more.tsx +1331 -0
  223. package/src/components/charts-radial.tsx +328 -0
  224. package/src/components/charts.tsx +390 -0
  225. package/src/components/checkbox.tsx +40 -0
  226. package/src/components/code.tsx +139 -0
  227. package/src/components/collapsible.tsx +37 -0
  228. package/src/components/combobox.tsx +339 -0
  229. package/src/components/command-extras.tsx +11 -0
  230. package/src/components/command.tsx +196 -0
  231. package/src/components/data-grid.tsx +213 -0
  232. package/src/components/data-table.tsx +334 -0
  233. package/src/components/data-views.tsx +740 -0
  234. package/src/components/date-picker.tsx +686 -0
  235. package/src/components/dense-table.tsx +207 -0
  236. package/src/components/dialog.tsx +162 -0
  237. package/src/components/dropdown-menu.tsx +264 -0
  238. package/src/components/empty.tsx +114 -0
  239. package/src/components/feedback-prompt.tsx +80 -0
  240. package/src/components/field.tsx +254 -0
  241. package/src/components/fields.tsx +112 -0
  242. package/src/components/flow.tsx +840 -0
  243. package/src/components/guide-cue.tsx +273 -0
  244. package/src/components/inbox.tsx +113 -0
  245. package/src/components/input-group.tsx +176 -0
  246. package/src/components/input.tsx +26 -0
  247. package/src/components/kbd.tsx +34 -0
  248. package/src/components/label.tsx +27 -0
  249. package/src/components/labels.tsx +176 -0
  250. package/src/components/motion.tsx +136 -0
  251. package/src/components/onboarding.tsx +454 -0
  252. package/src/components/order-book.tsx +185 -0
  253. package/src/components/pane-layout.tsx +417 -0
  254. package/src/components/popover.tsx +96 -0
  255. package/src/components/provider-logos.data.ts +49 -0
  256. package/src/components/radio-group.tsx +52 -0
  257. package/src/components/scheduler.tsx +997 -0
  258. package/src/components/segmented-control.tsx +28 -0
  259. package/src/components/select.tsx +195 -0
  260. package/src/components/separator.tsx +31 -0
  261. package/src/components/sheet.tsx +148 -0
  262. package/src/components/shell.tsx +426 -0
  263. package/src/components/shortcut-sheet.tsx +83 -0
  264. package/src/components/skeleton.tsx +18 -0
  265. package/src/components/sonner.tsx +107 -0
  266. package/src/components/spinner.tsx +13 -0
  267. package/src/components/split-button.tsx +58 -0
  268. package/src/components/stepper.tsx +78 -0
  269. package/src/components/switch.tsx +43 -0
  270. package/src/components/table.tsx +126 -0
  271. package/src/components/tabs.tsx +118 -0
  272. package/src/components/tag-input.tsx +253 -0
  273. package/src/components/textarea.tsx +21 -0
  274. package/src/components/theme.tsx +80 -0
  275. package/src/components/timeline.tsx +408 -0
  276. package/src/components/toggle-group.tsx +87 -0
  277. package/src/components/toggle.tsx +54 -0
  278. package/src/components/tooltip.tsx +65 -0
  279. package/src/components/workspace-actions.tsx +294 -0
  280. package/src/components/workspace-import.tsx +465 -0
  281. package/src/components/workspace.tsx +1884 -0
  282. package/src/index.ts +80 -0
  283. package/src/lib/active-indicator.tsx +53 -0
  284. package/src/lib/chart-kit.tsx +72 -0
  285. package/src/lib/cn.ts +6 -0
  286. package/src/lib/shortcuts.tsx +61 -0
  287. package/src/lib/workspace.tsx +10 -0
  288. package/src/shadcn/alert-dialog.tsx +196 -0
  289. package/src/shadcn/alert.tsx +65 -0
  290. package/src/shadcn/avatar.tsx +108 -0
  291. package/src/shadcn/badge.tsx +47 -0
  292. package/src/shadcn/breadcrumb.tsx +108 -0
  293. package/src/shadcn/button-group.tsx +83 -0
  294. package/src/shadcn/button.tsx +63 -0
  295. package/src/shadcn/calendar.tsx +220 -0
  296. package/src/shadcn/card.tsx +91 -0
  297. package/src/shadcn/checkbox.tsx +31 -0
  298. package/src/shadcn/collapsible.tsx +33 -0
  299. package/src/shadcn/command.tsx +184 -0
  300. package/src/shadcn/dialog.tsx +158 -0
  301. package/src/shadcn/dropdown-menu.tsx +256 -0
  302. package/src/shadcn/empty.tsx +103 -0
  303. package/src/shadcn/field.tsx +248 -0
  304. package/src/shadcn/input-group.tsx +170 -0
  305. package/src/shadcn/input.tsx +20 -0
  306. package/src/shadcn/kbd.tsx +28 -0
  307. package/src/shadcn/label.tsx +23 -0
  308. package/src/shadcn/popover.tsx +88 -0
  309. package/src/shadcn/radio-group.tsx +44 -0
  310. package/src/shadcn/select.tsx +189 -0
  311. package/src/shadcn/separator.tsx +27 -0
  312. package/src/shadcn/sheet.tsx +142 -0
  313. package/src/shadcn/skeleton.tsx +13 -0
  314. package/src/shadcn/sonner.tsx +40 -0
  315. package/src/shadcn/switch.tsx +34 -0
  316. package/src/shadcn/table.tsx +115 -0
  317. package/src/shadcn/tabs.tsx +90 -0
  318. package/src/shadcn/textarea.tsx +17 -0
  319. package/src/shadcn/toggle-group.tsx +83 -0
  320. package/src/shadcn/toggle.tsx +46 -0
  321. package/src/shadcn/tooltip.tsx +56 -0
  322. package/src/styles/palette.css +127 -0
  323. package/src/styles/theme.css +35 -0
  324. package/src/styles/twoux.css +236 -0
  325. package/theme.json +14 -0
@@ -0,0 +1,1884 @@
1
+ /**
2
+ * TwoUX compositions for a data workspace: a sheet of records whose columns are typed, calculated by a formula,
3
+ * written by the assistant or looked up from another service, each run per row at a stated cost.
4
+ *
5
+ * SheetGrid: a spreadsheet keyboard after Excel and Google Sheets (one tab stop; arrows, Shift+arrows and drag select a
6
+ * range; copy and paste tab-separated values to and from Excel or Sheets, validated per column; Delete clears; fill
7
+ * down and right; undo and redo; every action announced in the status line), size="dense" for 28px rows,
8
+ * optional group rows with totals, filter row, row selection, row menu and Open button, plus typed columns with a type icon, a menu per column, row numbers, a status row for computed columns,
9
+ * computed cell states, Add row and Add 10 rows, Wrap text per column, pinned first columns.
10
+ * ColumnsMenu: search, show or hide all, eye toggles, reorder by drag, by keyboard (pick up, arrows, drop, Esc) or by
11
+ * Move up and Move down buttons. Optional Wrap long text and Rows per page for list tables. Applies at once.
12
+ * RunStatusBar: "Table up to date", or progress with the cost so far and Stop; run history; Auto-run with its cost per row.
13
+ */
14
+ import {
15
+ Activity,
16
+ ArrowDown,
17
+ ArrowLeftToLine,
18
+ ArrowRightToLine,
19
+ ArrowUp,
20
+ ArrowUpDown,
21
+ Calendar,
22
+ Check,
23
+ ChevronDown,
24
+ ChevronRight,
25
+ CircleAlert,
26
+ EllipsisVertical,
27
+ Eye,
28
+ EyeOff,
29
+ FoldHorizontal,
30
+ GripVertical,
31
+ Hash,
32
+ History,
33
+ ListFilter,
34
+ Loader2,
35
+ Lock,
36
+ type LucideIcon,
37
+ MoveHorizontal,
38
+ PanelRight,
39
+ Pencil,
40
+ Pin,
41
+ PinOff,
42
+ Plug,
43
+ Plus,
44
+ RefreshCw,
45
+ Search,
46
+ Settings2,
47
+ Sigma,
48
+ Square,
49
+ SquareChevronDown,
50
+ ToggleRight,
51
+ Trash2,
52
+ Type,
53
+ UnfoldHorizontal,
54
+ WrapText,
55
+ Columns3,
56
+ } from "lucide-react"
57
+ import * as React from "react"
58
+ import { toast } from "sonner"
59
+ import { cn } from "../lib/cn"
60
+ import { useWorkspace } from "../lib/workspace"
61
+ import { AiMark } from "./ai-basics"
62
+ import { AiCell, type AiCellState } from "./ai-work"
63
+ import { Badge } from "./badge"
64
+ import { Button } from "./button"
65
+ import { Checkbox } from "./checkbox"
66
+ import {
67
+ DropdownMenu,
68
+ DropdownMenuContent,
69
+ DropdownMenuItem,
70
+ DropdownMenuLabel,
71
+ DropdownMenuRadioGroup,
72
+ DropdownMenuRadioItem,
73
+ DropdownMenuSeparator,
74
+ DropdownMenuShortcut,
75
+ DropdownMenuSub,
76
+ DropdownMenuSubContent,
77
+ DropdownMenuSubTrigger,
78
+ DropdownMenuTrigger,
79
+ } from "./dropdown-menu"
80
+ import { Input } from "./input"
81
+ import { ShimmerText } from "./motion"
82
+ import { ProviderLogo } from "./onboarding"
83
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover"
84
+ import { RadioGroup, RadioGroupItem } from "./radio-group"
85
+ import type { ProviderId } from "./provider-logos.data"
86
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"
87
+ import type { ShortcutGroup } from "./shortcut-sheet"
88
+ import { Switch } from "./switch"
89
+
90
+ /* ---------------------------------------------------------------- types */
91
+
92
+ export type ColumnType = "text" | "number" | "date" | "select" | "boolean" | "formula" | "ai" | "enrichment"
93
+
94
+ export const COLUMN_TYPES: Record<ColumnType, { label: string; icon: LucideIcon | null; computed: boolean }> = {
95
+ text: { label: "Text", icon: Type, computed: false },
96
+ number: { label: "Number", icon: Hash, computed: false },
97
+ date: { label: "Date", icon: Calendar, computed: false },
98
+ select: { label: "Select", icon: SquareChevronDown, computed: false },
99
+ boolean: { label: "Yes or no", icon: ToggleRight, computed: false },
100
+ formula: { label: "Formula", icon: Sigma, computed: true },
101
+ ai: { label: "AI", icon: null, computed: true },
102
+ enrichment: { label: "Enrichment", icon: Plug, computed: true },
103
+ }
104
+
105
+ /** Progress of a computed column, shown in the status row under its header. */
106
+ export type ColumnRunStatus = { done: number; total: number; errors?: number; running?: boolean; stale?: boolean }
107
+
108
+ export type SheetColumn = {
109
+ id: string
110
+ label: string
111
+ type: ColumnType
112
+ /** px */
113
+ width: number
114
+ pinned?: boolean
115
+ hidden?: boolean
116
+ wrap?: boolean
117
+ /** Always shown, can't be hidden or deleted: the column that names the record. */
118
+ locked?: boolean
119
+ /** Choices for a select column. */
120
+ options?: string[]
121
+ /** Shown in the column menu of a formula column, e.g. "=Seniority & Company". */
122
+ formula?: string
123
+ /** The service an enrichment column looks values up in. Its logo replaces the type icon. */
124
+ provider?: ProviderId
125
+ /** Credits per row, stated before every run and retry. */
126
+ costPerRow?: number
127
+ /** Return a message to block the save or the paste. */
128
+ validate?: (value: string) => string | undefined
129
+ status?: ColumnRunStatus
130
+ /** Number columns: an ISO currency code (GBP) formats values as money; pasted £, $ and € are understood. */
131
+ currency?: string
132
+ /** Show a total in the footer, and in each group row when rows are grouped. */
133
+ total?: "sum" | "average"
134
+ /** Yes or no columns: the word beside the switch, e.g. "Subscribed" and "Not subscribed". */
135
+ onLabel?: string
136
+ offLabel?: string
137
+ /** Rows where the column doesn't apply show "n/a" (read as "not applicable") and can't be edited or pasted into. */
138
+ notApplicable?: (row: SheetRow) => boolean
139
+ /** Custom display for a typed column, such as an avatar beside an owner's name. Editing still uses the column's type. */
140
+ render?: (value: SheetValue, row: SheetRow) => React.ReactNode
141
+ }
142
+
143
+ export type ComputedCellState = AiCellState | { state: "not-found" }
144
+ export type SheetValue = string | number | boolean | null | undefined
145
+ export type SheetRow = { id: string; values: Record<string, SheetValue>; computed?: Record<string, ComputedCellState> }
146
+ export type SheetSort = { columnId: string; direction: "asc" | "desc" } | null
147
+
148
+ const isComputed = (c: SheetColumn) => COLUMN_TYPES[c.type].computed
149
+ const fmtNum = (n: number) => n.toLocaleString("en-GB", { maximumFractionDigits: 2 })
150
+ export const formatCredits = (n: number) => `${n.toLocaleString("en-GB", { maximumFractionDigits: 1 })} ${n === 1 ? "credit" : "credits"}`
151
+ const fmtDate = (v: string) => {
152
+ const d = new Date(v)
153
+ return Number.isNaN(d.getTime()) ? v : new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" }).format(d)
154
+ }
155
+
156
+ /** The type icon of a column: the provider's logo for enrichment, the origin mark for AI, otherwise a muted icon. */
157
+ export function ColumnTypeIcon({ type, provider, className }: { type: ColumnType; provider?: ProviderId; className?: string }) {
158
+ if (type === "enrichment" && provider) return <ProviderLogo provider={provider} size="sm" className={cn("size-4 p-px", className)} />
159
+ if (type === "ai") return <AiMark className={cn("size-3.5", className)} />
160
+ const Icon = COLUMN_TYPES[type].icon ?? Type
161
+ return <Icon aria-hidden="true" className={cn("size-3.5 shrink-0 text-ink-3", className)} />
162
+ }
163
+
164
+ const SORT_WORDS: Record<string, [string, string]> = { number: ["Sort smallest first", "Sort largest first"], date: ["Sort oldest first", "Sort newest first"] }
165
+
166
+ function statusText(s: ColumnRunStatus) {
167
+ const pct = s.total ? Math.floor((s.done / s.total) * 100) : 100
168
+ const errs = s.errors ? `${s.errors} ${s.errors === 1 ? "error" : "errors"}` : ""
169
+ if (s.running) return { tone: "running" as const, text: `${pct}%`, errs }
170
+ if (s.stale) return { tone: "stale" as const, text: "Out of date", errs }
171
+ if (s.errors) return { tone: "error" as const, text: errs, errs: "" }
172
+ if (s.done < s.total) return { tone: "partial" as const, text: `${pct}% run`, errs }
173
+ return { tone: "ok" as const, text: "Up to date", errs }
174
+ }
175
+
176
+ function ColumnStatus({ status }: { status: ColumnRunStatus }) {
177
+ const s = statusText(status)
178
+ return (
179
+ <span className="inline-flex min-w-0 items-center gap-control text-sm whitespace-nowrap">
180
+ {s.tone === "running" && <Loader2 className="size-3 shrink-0 animate-spin text-ink-3" aria-hidden="true" />}
181
+ {s.tone === "ok" && <Check className="size-3 shrink-0 text-ok" aria-hidden="true" />}
182
+ {s.tone === "error" && <CircleAlert className="size-3 shrink-0 text-bad" aria-hidden="true" />}
183
+ {s.tone === "stale" && <RefreshCw className="size-3 shrink-0 text-warn" aria-hidden="true" />}
184
+ <span className={cn("tabular", s.tone === "error" ? "text-bad" : s.tone === "ok" ? "text-ink-2" : "text-ink-2")}>{s.text}</span>
185
+ {s.errs && <span className="tabular text-bad">· {s.errs}</span>}
186
+ </span>
187
+ )
188
+ }
189
+
190
+ function ComputedCell({ col, cell, wrap }: { col: SheetColumn; cell?: ComputedCellState; wrap?: boolean }) {
191
+ if (!cell) return <span className="text-ink-3"><span aria-hidden="true">–</span><span className="sr-only">Empty</span></span>
192
+ if (cell.state === "not-found") return <span className="text-sm text-ink-3">Not found</span>
193
+ if (col.type === "ai" && cell.state !== "done") return <AiCell cell={cell} />
194
+ if (cell.state === "queued") return <span className="text-sm text-ink-3">Waiting</span>
195
+ if (cell.state === "writing")
196
+ return <span role="status" className="inline-flex items-center gap-control text-sm text-ink-3"><Loader2 className="size-3 animate-spin" aria-hidden="true" />{col.type === "formula" ? "Calculating" : "Looking up"}</span>
197
+ if (cell.state === "failed")
198
+ return (
199
+ <span className="inline-flex min-w-0 items-center gap-control text-sm text-bad">
200
+ <CircleAlert className="size-3.5 shrink-0" aria-hidden="true" />
201
+ <span className={wrap ? "whitespace-normal" : "truncate"}>{cell.reason}</span>
202
+ </span>
203
+ )
204
+ return <span className={cn("block", wrap ? "whitespace-normal break-words" : "truncate")}>{cell.value}</span>
205
+ }
206
+
207
+ /* ---------------------------------------------------------------- spreadsheet helpers */
208
+
209
+ type Cell = { r: number; c: number }
210
+ type Rect = { r1: number; c1: number; r2: number; c2: number }
211
+ type CellChange = { rowId: string; columnId: string; before: SheetValue; after: SheetValue }
212
+ type Batch = { label: string; changes: CellChange[] }
213
+ type Reject = { key: string; where: string; reason: string }
214
+ /** One cell written by a paste, fill, clear, undo or edit. */
215
+ export type SheetCellChange = { rowId: string; columnId: string; value: SheetValue }
216
+
217
+ const rectOf = (a: Cell, b: Cell): Rect => ({ r1: Math.min(a.r, b.r), c1: Math.min(a.c, b.c), r2: Math.max(a.r, b.r), c2: Math.max(a.c, b.c) })
218
+ const isBlank = (v: SheetValue) => v === null || v === undefined || v === ""
219
+ const detectMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
220
+ const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-GB")} ${n === 1 ? one : many}`
221
+ const TRUE_WORDS = ["true", "yes", "y", "1", "on", "x", "✓"]
222
+ const FALSE_WORDS = ["false", "no", "n", "0", "off"]
223
+
224
+ /** Tab-separated values, quoted the way Excel and Google Sheets quote them, so a copied range pastes into either. */
225
+ export function toTsv(cells: string[][]) {
226
+ return cells.map((row) => row.map((s) => (/[\t\n\r"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s)).join("\t")).join("\n")
227
+ }
228
+
229
+ /** Reads what Excel or Google Sheets put on the clipboard: tabs between cells, newlines between rows, quotes around a cell that holds either. */
230
+ export function parseTsv(text: string): string[][] {
231
+ const t = text.replace(/\r\n?/g, "\n").replace(/\n$/, "")
232
+ const out: string[][] = []
233
+ let row: string[] = []
234
+ let cell = ""
235
+ let quoted = false
236
+ for (let i = 0; i < t.length; i++) {
237
+ const ch = t[i]
238
+ if (quoted) {
239
+ if (ch === '"' && t[i + 1] === '"') { cell += '"'; i++ }
240
+ else if (ch === '"') quoted = false
241
+ else cell += ch
242
+ } else if (ch === '"' && cell === "") quoted = true
243
+ else if (ch === "\t") { row.push(cell); cell = "" }
244
+ else if (ch === "\n") { row.push(cell); out.push(row); row = []; cell = "" }
245
+ else cell += ch
246
+ }
247
+ row.push(cell)
248
+ out.push(row)
249
+ return out
250
+ }
251
+
252
+ const pad = (n: number) => String(n).padStart(2, "0")
253
+ /** 2025-03-14, 14/03/2025 (day first, as in the UK) or 14 Mar 2025, as an ISO date (2025-03-14), or null when it is not a real date. */
254
+ export function toIsoDate(v: string): string | null {
255
+ const uk = v.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/)
256
+ const iso = v.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
257
+ let y: number, m: number, d: number
258
+ if (uk) [d, m, y] = [+uk[1], +uk[2], +uk[3]]
259
+ else if (iso) [y, m, d] = [+iso[1], +iso[2], +iso[3]]
260
+ else {
261
+ if (!/[a-z]/i.test(v)) return null
262
+ const t = new Date(v)
263
+ if (Number.isNaN(t.getTime())) return null
264
+ ;[y, m, d] = [t.getFullYear(), t.getMonth() + 1, t.getDate()]
265
+ }
266
+ const t = new Date(Date.UTC(y, m - 1, d))
267
+ if (t.getUTCMonth() !== m - 1 || t.getUTCDate() !== d) return null
268
+ return `${y}-${pad(m)}-${pad(d)}`
269
+ }
270
+
271
+ const computedHow = (col: SheetColumn) => (col.type === "formula" ? "calculated by a formula" : col.type === "ai" ? "written by the assistant" : "looked up")
272
+
273
+ /** Turns typed or pasted text into the column's value, or says why it doesn't fit. */
274
+ function coerce(col: SheetColumn, raw: string, row: SheetRow): { value: SheetValue } | { reason: string } {
275
+ if (isComputed(col)) return { reason: `${col.label} is ${computedHow(col)}, so it can't be typed or pasted into.` }
276
+ if (col.notApplicable?.(row)) return { reason: `${col.label} doesn't apply to this row.` }
277
+ const v = raw.trim()
278
+ if (col.type === "boolean") {
279
+ const w = v.toLowerCase()
280
+ if (TRUE_WORDS.includes(w) || (col.onLabel && w === col.onLabel.toLowerCase())) return { value: true }
281
+ if (w === "" || FALSE_WORDS.includes(w) || (col.offLabel && w === col.offLabel.toLowerCase())) return { value: false }
282
+ return { reason: `Enter yes or no${col.onLabel ? `, or ${col.onLabel}` : ""}.` }
283
+ }
284
+ const custom = col.validate?.(v)
285
+ if (custom) return { reason: custom }
286
+ if (v === "") return { value: "" }
287
+ if (col.type === "number") {
288
+ const n = Number(v.replace(/[,\s£$€]/g, "").replace(/^\((.*)\)$/, "-$1"))
289
+ return Number.isNaN(n) ? { reason: "Enter a number, such as 1200." } : { value: n }
290
+ }
291
+ if (col.type === "date") {
292
+ const d = toIsoDate(v)
293
+ return d ? { value: d } : { reason: "Enter a date, such as 2025-03-14 or 14/03/2025." }
294
+ }
295
+ if (col.type === "select") {
296
+ const o = (col.options ?? []).find((x) => x.toLowerCase() === v.toLowerCase())
297
+ return o ? { value: o } : { reason: `Choose one of ${(col.options ?? []).join(", ")}.` }
298
+ }
299
+ return { value: raw.replace(/\r/g, "") }
300
+ }
301
+
302
+ const valueOf = (col: SheetColumn, row: SheetRow): SheetValue => {
303
+ if (isComputed(col)) { const c = row.computed?.[col.id]; return c?.state === "done" ? c.value : null }
304
+ return row.values[col.id]
305
+ }
306
+ /** What a cell puts on the clipboard: the raw value, so numbers stay numbers in Excel and Google Sheets. */
307
+ const copyText = (col: SheetColumn, row: SheetRow) => {
308
+ if (col.notApplicable?.(row)) return "n/a"
309
+ const v = valueOf(col, row)
310
+ if (col.type === "boolean") return v ? "TRUE" : "FALSE"
311
+ return isBlank(v) ? "" : String(v)
312
+ }
313
+ const fmtFor = (col: SheetColumn | undefined, n: number) =>
314
+ col?.currency ? n.toLocaleString("en-GB", { style: "currency", currency: col.currency, maximumFractionDigits: Number.isInteger(n) ? 0 : 2 }) : fmtNum(n)
315
+ const numbersOf = (col: SheetColumn, rows: SheetRow[]) =>
316
+ rows.map((r) => valueOf(col, r)).filter((v) => (typeof v === "number" ? true : typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)))).map(Number)
317
+ const aggregate = (col: SheetColumn, rows: SheetRow[]) => {
318
+ const nums = numbersOf(col, rows)
319
+ if (!nums.length) return null
320
+ const sum = nums.reduce((a, b) => a + b, 0)
321
+ return col.total === "average" ? sum / nums.length : sum
322
+ }
323
+
324
+ type Line = { kind: "row"; row: SheetRow; n: number } | { kind: "group"; key: string; label: string; rows: SheetRow[]; open: boolean }
325
+
326
+ /** The SheetGrid keys, for the ShortcutSheet. ⌘ on a Mac, Ctrl elsewhere. */
327
+ export function sheetGridShortcuts(mac = detectMac()): ShortcutGroup[] {
328
+ const M = mac ? "⌘" : "Ctrl"
329
+ return [
330
+ {
331
+ title: "Sheet: move and select",
332
+ items: [
333
+ { keys: ["←", "→", "↑", "↓"], action: "Move one cell" },
334
+ { keys: [M, "Arrow"], action: "Jump to the edge of the data" },
335
+ { keys: ["Home", "End"], action: "First or last cell in the row" },
336
+ { keys: [M, "Home", "End"], action: "First or last cell in the sheet" },
337
+ { keys: ["Page Up", "Page Down"], action: "Move 10 rows" },
338
+ { keys: ["⇧", "Arrow"], action: "Extend the selection" },
339
+ { keys: [M, "⇧", "Arrow"], action: "Extend the selection to the edge of the data" },
340
+ { keys: [M, "A"], action: "Select all cells" },
341
+ { keys: ["Ctrl", "Space"], action: "Select the column" },
342
+ { keys: ["⇧", "Space"], action: "Select or deselect the rows" },
343
+ { keys: ["⇧", "F10"], action: "Open the row's actions" },
344
+ { keys: [mac ? "⌥" : "Alt", "⇧", "← →"], action: "On a header: make the column narrower or wider" },
345
+ { keys: ["Esc"], action: "Clear the selection" },
346
+ ],
347
+ },
348
+ {
349
+ title: "Sheet: edit",
350
+ items: [
351
+ { keys: ["Enter", "F2"], action: "Edit the cell; start typing to replace it" },
352
+ { keys: ["Enter"], action: "Save and move down (⇧ Enter: up)" },
353
+ { keys: ["Tab"], action: "Save and move right (⇧ Tab: left)" },
354
+ { keys: [M, "Enter"], action: "Save the value into every selected cell" },
355
+ { keys: ["Esc"], action: "Cancel the edit" },
356
+ { keys: ["Delete"], action: "Clear the selected cells" },
357
+ { keys: [M, "C"], action: "Copy, to paste here, in Excel or in Google Sheets" },
358
+ { keys: [M, "X"], action: "Copy and clear" },
359
+ { keys: [M, "V"], action: "Paste from here, Excel or Google Sheets" },
360
+ { keys: [M, "D"], action: "Fill down" },
361
+ { keys: [M, "R"], action: "Fill right" },
362
+ { keys: [M, "Z"], action: "Undo" },
363
+ { keys: mac ? [M, "⇧", "Z"] : ["Ctrl", "Y"], action: "Redo" },
364
+ { keys: ["Space"], action: "Turn a yes or no cell on or off" },
365
+ { keys: [M, ";"], action: "Put today's date in a date cell" },
366
+ { keys: [M, "/"], action: "Show these shortcuts" },
367
+ ],
368
+ },
369
+ ]
370
+ }
371
+
372
+ type SheetRowApi = {
373
+ mouseDown: (e: React.MouseEvent, r: number, c: number, isEditing: boolean) => void
374
+ click: (e: React.MouseEvent) => void
375
+ mouseEnter: (e: React.MouseEvent, r: number, c: number) => void
376
+ doubleClick: () => void
377
+ contextMenu: (e: React.MouseEvent, r: number, c: number, row: SheetRow) => void
378
+ renderValue: (col: SheetColumn, row: SheetRow) => React.ReactNode
379
+ editor: (col: SheetColumn, row: SheetRow) => React.ReactNode
380
+ rowMenu: (row: SheetRow) => React.ReactNode
381
+ toggleRow: (row: SheetRow) => void
382
+ rowName: (row: SheetRow) => string
383
+ openRow: (id: string) => void
384
+ }
385
+
386
+ const SheetBodyRow = React.memo(function SheetBodyRow({
387
+ api, row, n, r, rowIndex, visible, lefts, cellCls, dense, activeC, editing, error, selC1, selC2, rejected, checked, selectable, selecting, slot, hasRowMenu, menuOpen, openColId,
388
+ }: {
389
+ api: SheetRowApi
390
+ row: SheetRow
391
+ n: number
392
+ r: number
393
+ rowIndex: number
394
+ visible: SheetColumn[]
395
+ lefts: number[]
396
+ cellCls: string
397
+ dense: boolean
398
+ /** The active cell's column in this row, or -1. */
399
+ activeC: number
400
+ editing: string | null
401
+ error: string
402
+ /** The selected range's columns in this row when more than one cell is selected, or -1. */
403
+ selC1: number
404
+ selC2: number
405
+ rejected: Record<string, string>
406
+ checked: boolean
407
+ selectable: boolean
408
+ /** Some row is checked: every row shows its checkbox instead of its number. */
409
+ selecting: boolean
410
+ /** Width of the number or checkbox, px. */
411
+ slot: number
412
+ hasRowMenu: boolean
413
+ /** Re-renders the row when its menu opens or closes. */
414
+ menuOpen: boolean
415
+ openColId?: string
416
+ }) {
417
+ const name = api.rowName(row)
418
+ return (
419
+ <tr aria-rowindex={rowIndex} aria-selected={selectable ? checked : undefined} className="group/row">
420
+ <th scope="row" className={cn("sticky left-0 z-10 border-r border-b border-line px-1 text-sm font-normal", dense ? "h-7" : "h-9", activeC >= 0 ? "bg-primary-soft font-medium text-primary-ink" : checked ? "bg-primary-soft text-ink-3" : "bg-surface-2 text-ink-3 group-hover/row:bg-hover")}>
421
+ {/* The number and the checkbox share one slot: the checkbox shows on hover, on focus and while any row is checked. */}
422
+ <span className="flex items-center">
423
+ <span style={{ "--s": `${slot}px` } as React.CSSProperties} className="group/slot relative grid h-5 w-(--s) shrink-0 place-items-center">
424
+ <span className={cn("tabular w-full text-right", selectable && (checked || selecting ? "invisible" : "group-hover/row:invisible pointer-coarse:invisible"))}>{n}</span>
425
+ {selectable && (
426
+ <span className={cn("absolute inset-0 grid place-items-center", !(checked || selecting) && "opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100")}>
427
+ <Checkbox tabIndex={-1} aria-label={`Select ${name}`} checked={checked} onCheckedChange={() => api.toggleRow(row)} />
428
+ </span>
429
+ )}
430
+ </span>
431
+ {hasRowMenu && <span className={cn("flex", !menuOpen && "opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100")}>{api.rowMenu(row)}</span>}
432
+ </span>
433
+ </th>
434
+ {visible.map((c, ci) => {
435
+ const here = activeC === ci
436
+ const isEditing = here && editing !== null
437
+ const failed = row.computed?.[c.id]?.state === "failed"
438
+ const selected = selC1 >= 0 && ci >= selC1 && ci <= selC2
439
+ const why = rejected[`${row.id}:${c.id}`]
440
+ const openHere = openColId === c.id && !isEditing
441
+ const pinned = c.pinned
442
+ return (
443
+ <td
444
+ key={c.id}
445
+ role="gridcell"
446
+ data-cell={`${r}:${ci}`}
447
+ tabIndex={here && !isEditing ? 0 : -1}
448
+ aria-selected={selected || here}
449
+ aria-readonly={isComputed(c) || !!c.notApplicable?.(row) || undefined}
450
+ aria-invalid={failed || !!why || undefined}
451
+ title={why ? `Not pasted: ${why}` : undefined}
452
+ onMouseDown={(e) => api.mouseDown(e, r, ci, isEditing)}
453
+ onClick={(e) => api.click(e)}
454
+ onMouseEnter={(e) => api.mouseEnter(e, r, ci)}
455
+ onDoubleClick={() => api.doubleClick()}
456
+ onContextMenu={(e) => api.contextMenu(e, r, ci, row)}
457
+ style={pinned ? ({ "--l": `${lefts[ci]}px` } as React.CSSProperties) : undefined}
458
+ className={cn(
459
+ "group/cell scroll-mt-(--hh) scroll-mb-(--fh) scroll-ml-(--pl) border-b border-line",
460
+ cellCls,
461
+ c.wrap ? "py-2 align-top" : "whitespace-nowrap",
462
+ !isEditing && "select-none",
463
+ // The row being worked on is tinted; under the pointer, the row takes --color-hover and the cell --color-selected.
464
+ // The active cell stays on the surface with a 2px primary outline, so it reads at a glance.
465
+ selected ? "bg-primary-soft" : here ? "bg-surface" : activeC >= 0 ? "bg-active-row" : "bg-surface group-hover/row:bg-hover",
466
+ pinned && cn("sticky left-(--l) z-1", !visible[ci + 1]?.pinned && "border-r"),
467
+ c.type === "number" && "tabular text-right",
468
+ !isEditing && !selected && !here && "hover:bg-selected hover:shadow-[inset_0_0_0_1px_var(--color-line-strong)]",
469
+ why && !here && "outline-1 -outline-offset-1 outline-dashed outline-bad",
470
+ here ? "outline-2 -outline-offset-2 outline-solid" : "outline-none",
471
+ here && (isEditing && error ? "outline-bad" : "outline-primary")
472
+ )}
473
+ >
474
+ {isEditing ? api.editor(c, row) : openHere ? (
475
+ <span className="flex min-w-0 items-center gap-control">
476
+ <span className="min-w-0 flex-1">{api.renderValue(c, row)}</span>
477
+ <Button
478
+ variant="outline"
479
+ size="xs"
480
+ tabIndex={-1}
481
+ aria-label={`Open ${name}`}
482
+ onMouseDown={(e) => e.stopPropagation()}
483
+ onDoubleClick={(e) => e.stopPropagation()}
484
+ onClick={() => api.openRow(row.id)}
485
+ className="h-5 opacity-0 transition-opacity duration-(--duration-fast) group-hover/row:opacity-100 group-focus-within/row:opacity-100 pointer-coarse:opacity-100"
486
+ >
487
+ Open
488
+ </Button>
489
+ </span>
490
+ ) : api.renderValue(c, row)}
491
+ {why && <span className="sr-only">Not pasted: {why}</span>}
492
+ </td>
493
+ )
494
+ })}
495
+ </tr>
496
+ )
497
+ })
498
+
499
+ /* ---------------------------------------------------------------- sheet grid */
500
+
501
+ export function SheetGrid({
502
+ columns,
503
+ onColumnsChange,
504
+ rows,
505
+ onCellChange,
506
+ onCellsChange,
507
+ sort = null,
508
+ onSortChange,
509
+ onFilterColumn,
510
+ onInsertColumn,
511
+ onEditColumn,
512
+ onRetryCell,
513
+ onRetryColumn,
514
+ onAddRows,
515
+ size = "default",
516
+ groupBy,
517
+ filterRow = false,
518
+ selectedRowIds,
519
+ onSelectedRowIdsChange,
520
+ rowActions,
521
+ onOpenRow,
522
+ "aria-label": label,
523
+ className,
524
+ }: {
525
+ columns: SheetColumn[]
526
+ /** Rename, type, wrap, freeze, hide, insert and delete all come back through here. Delete shows Undo. */
527
+ onColumnsChange: (columns: SheetColumn[]) => void
528
+ rows: SheetRow[]
529
+ /** Called once per cell. Use a functional state update: a paste or fill calls it for every cell it writes. */
530
+ onCellChange: (rowId: string, columnId: string, value: string | number | boolean) => void
531
+ /** Optional: receive a paste, fill, clear, undo or redo as one batch instead of one onCellChange per cell. */
532
+ onCellsChange?: (changes: SheetCellChange[]) => void
533
+ sort?: SheetSort
534
+ onSortChange?: (sort: SheetSort) => void
535
+ /** Adds a filter pill for the column to the page's FilterBar. */
536
+ onFilterColumn?: (columnId: string) => void
537
+ /** Insert a column at this index of `columns`. Without it, a text column is inserted and renamed in place. */
538
+ onInsertColumn?: (index: number) => void
539
+ /** Open the set-up of a computed column (prompt, inputs, provider). */
540
+ onEditColumn?: (columnId: string) => void
541
+ /** Run one failed cell again. Enter on a failed cell calls it; the status line states the cost. */
542
+ onRetryCell?: (rowId: string, columnId: string) => void
543
+ /** Run every failed cell in the column again. */
544
+ onRetryColumn?: (columnId: string) => void
545
+ onAddRows?: (count: number) => void
546
+ /** dense: 28px rows, only for spreadsheet-like data (inventories, ledgers, imports, mapping). Never a user preference. */
547
+ size?: "default" | "dense"
548
+ /** Group rows by this column's value, with a collapsible group row that shows the count and each total column's sum. */
549
+ groupBy?: string
550
+ /** A search field under each header that filters the rows by that column. */
551
+ filterRow?: boolean
552
+ /** Adds a checkbox to each row number. Shift+Space selects the rows of the selected cells. Pair with BulkActionBar. */
553
+ selectedRowIds?: string[]
554
+ onSelectedRowIdsChange?: (ids: string[]) => void
555
+ /** Items for the row's ⋮ menu (DropdownMenuItem). Opens from the row number, a right click, or ⇧F10. */
556
+ rowActions?: (row: SheetRow) => React.ReactNode
557
+ /** Adds Open to the row menu and an Open button in the name cell, shown on hover and focus. */
558
+ onOpenRow?: (rowId: string) => void
559
+ "aria-label": string
560
+ className?: string
561
+ }) {
562
+ const dense = size === "dense"
563
+ const isMac = React.useMemo(detectMac, [])
564
+ const M = isMac ? "⌘" : "Ctrl+"
565
+ const mod = (e: { metaKey: boolean; ctrlKey: boolean }) => (isMac ? e.metaKey : e.ctrlKey)
566
+
567
+ const visible = React.useMemo(() => {
568
+ const v = columns.filter((c) => !c.hidden)
569
+ return [...v.filter((c) => c.pinned), ...v.filter((c) => !c.pinned)]
570
+ }, [columns])
571
+
572
+ const [filters, setFilters] = React.useState<Record<string, string>>({})
573
+ const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
574
+
575
+ const filteredRows = React.useMemo(() => {
576
+ const active = Object.entries(filters).filter(([, q]) => q.trim())
577
+ if (!active.length) return rows
578
+ return rows.filter((r) => active.every(([id, q]) => {
579
+ const col = columns.find((c) => c.id === id)
580
+ return !col || copyText(col, r).toLowerCase().includes(q.trim().toLowerCase())
581
+ }))
582
+ }, [rows, filters, columns])
583
+
584
+ const shownRows = React.useMemo(() => {
585
+ if (!sort) return filteredRows
586
+ const col = columns.find((c) => c.id === sort.columnId)
587
+ if (!col) return filteredRows
588
+ const dir = sort.direction === "asc" ? 1 : -1
589
+ return [...filteredRows].sort((a, b) => {
590
+ const x = valueOf(col, a), y = valueOf(col, b)
591
+ const ex = isBlank(x), ey = isBlank(y)
592
+ if (ex || ey) return ex === ey ? 0 : ex ? 1 : -1
593
+ if (col.type === "number") return (Number(x) - Number(y)) * dir
594
+ return String(x).localeCompare(String(y), "en-GB", { numeric: true }) * dir
595
+ })
596
+ }, [filteredRows, sort, columns])
597
+
598
+ const lines = React.useMemo<Line[]>(() => {
599
+ const gcol = groupBy ? columns.find((c) => c.id === groupBy) : undefined
600
+ if (!gcol) return shownRows.map((row, i) => ({ kind: "row" as const, row, n: i + 1 }))
601
+ const groups = new Map<string, SheetRow[]>()
602
+ for (const o of gcol.options ?? []) groups.set(o, [])
603
+ for (const row of shownRows) {
604
+ const k = copyText(gcol, row) || "(Empty)"
605
+ if (!groups.has(k)) groups.set(k, [])
606
+ groups.get(k)!.push(row)
607
+ }
608
+ // Rows are numbered in the order they show, counting collapsed groups too, so numbers don't shift on collapse.
609
+ const out: Line[] = []
610
+ let n = 0
611
+ for (const [key, rs] of groups) {
612
+ if (!rs.length) continue
613
+ const open = !collapsed.has(key)
614
+ out.push({ kind: "group", key, label: key, rows: rs, open })
615
+ for (const row of rs) { n++; if (open) out.push({ kind: "row", row, n }) }
616
+ }
617
+ return out
618
+ }, [shownRows, groupBy, columns, collapsed])
619
+
620
+ const rowAt = (r: number) => { const l = lines[r]; return l?.kind === "row" ? l.row : null }
621
+ const dataLines = (R: Rect) => {
622
+ const out: { r: number; row: SheetRow; n: number }[] = []
623
+ for (let r = Math.max(0, R.r1); r <= R.r2; r++) { const l = lines[r]; if (l?.kind === "row") out.push({ r, row: l.row, n: l.n }) }
624
+ return out
625
+ }
626
+
627
+ const top = filterRow ? -2 : -1
628
+ const [pos, setPos] = React.useState<Cell>({ r: 0, c: 0 })
629
+ const [ext, setExt] = React.useState<Cell>({ r: 0, c: 0 })
630
+ const [selAll, setSelAll] = React.useState(false)
631
+ const [editing, setEditing] = React.useState<string | null>(null)
632
+ const [error, setError] = React.useState("")
633
+ const [status, setStatusText] = React.useState("")
634
+ const [addSaid, setAddSaid] = React.useState("")
635
+ const [tone, setTone] = React.useState<"plain" | "bad">("plain")
636
+ const [rejected, setRejected] = React.useState<Record<string, string>>({})
637
+ const [menuFor, setMenuFor] = React.useState<string | null>(null)
638
+ const [rowMenu, setRowMenu] = React.useState<string | null>(null)
639
+ const [renaming, setRenaming] = React.useState<string | null>(null)
640
+ const renameNext = React.useRef(false)
641
+ const grid = React.useRef<HTMLTableElement>(null)
642
+ const dragging = React.useRef(false)
643
+ const history = React.useRef<{ undo: Batch[]; redo: Batch[] }>({ undo: [], redo: [] })
644
+ const clip = React.useRef<{ kind: "copy" | "cut" | "paste"; handled: boolean } | null>(null)
645
+ const clickOpens = React.useRef(false)
646
+ /** Row count before Add row, so the grid can move to the first new row once it arrives. */
647
+ const adding = React.useRef<number | null>(null)
648
+
649
+ const say = (msg: string, t: "plain" | "bad" = "plain") => { setStatusText(msg); setTone(t) }
650
+ const setStatus = (msg: string) => say(msg)
651
+
652
+ const selection = new Set(selectedRowIds ?? [])
653
+ const selectable = !!onSelectedRowIdsChange
654
+ const hasRowMenu = !!rowActions || !!onOpenRow
655
+ const hasStatusRow = visible.some((c) => isComputed(c) && c.status)
656
+ const hasTotals = visible.some((c) => c.total)
657
+ const H = dense ? 28 : 36
658
+ // The row header is one slot (the number, or the checkbox in its place) plus the ⋮ menu: about 47px for 150 rows.
659
+ const SLOT = Math.max(16, String(Math.max(1, rows.length)).length * 7 + 2)
660
+ const ROWHEAD = 8 + SLOT + (hasRowMenu ? 16 : 0)
661
+ // Live width while a column edge is dragged; saved through onColumnsChange on release.
662
+ const [drag, setDrag] = React.useState<{ id: string; w: number } | null>(null)
663
+ const wOf = (c: SheetColumn) => (drag?.id === c.id ? drag.w : c.width)
664
+ // Column status (the run's progress) changes every tick but never shows in body cells, so it doesn't re-render rows.
665
+ // Frozen columns never take more than 60% of the grid's width, so a narrow screen can still scroll sideways.
666
+ const boxRef = React.useRef<HTMLDivElement>(null)
667
+ const [boxW, setBoxW] = React.useState(0)
668
+ React.useEffect(() => {
669
+ const el = boxRef.current
670
+ if (!el || typeof ResizeObserver === "undefined") return
671
+ const ro = new ResizeObserver(([e]) => setBoxW(Math.round(e.contentRect.width)))
672
+ ro.observe(el)
673
+ return () => ro.disconnect()
674
+ }, [])
675
+ const frozen = (() => {
676
+ const ids: string[] = []
677
+ let w = ROWHEAD
678
+ for (const c of visible) {
679
+ if (!c.pinned || (boxW && w + c.width > boxW * 0.6)) break
680
+ ids.push(c.id)
681
+ w += c.width
682
+ }
683
+ return ids.join("|")
684
+ })()
685
+ // Column status (the run's progress) changes every tick but never shows in body cells, so it doesn't re-render rows.
686
+ const bodySig = frozen + "#" + visible.map((c) => [c.id, c.label, c.type, c.width, c.wrap, c.currency, c.onLabel, c.offLabel, c.options?.join("/"), c.validate, c.render, c.notApplicable].map(String).join("~")).join("|")
687
+ const bodyCols = React.useMemo(() => {
688
+ const ids = new Set(frozen.split("|"))
689
+ return visible.map((c) => (!!c.pinned === ids.has(c.id) ? c : { ...c, pinned: ids.has(c.id) }))
690
+ // eslint-disable-next-line react-hooks/exhaustive-deps
691
+ }, [bodySig])
692
+ // Only a pinned column's drag moves the columns after it, so dragging an ordinary edge doesn't re-render the rows.
693
+ const pinDrag = drag && bodyCols.find((c) => c.id === drag.id)?.pinned ? drag : null
694
+ const lefts = React.useMemo(() => bodyCols.reduce<number[]>((acc, c, i) => {
695
+ const p = bodyCols[i - 1]
696
+ return [...acc, i === 0 ? ROWHEAD : acc[i - 1] + (p.pinned ? (pinDrag?.id === p.id ? pinDrag.w : p.width) : 0)]
697
+ }, []), [bodyCols, ROWHEAD, pinDrag])
698
+ const pinnedWidth = ROWHEAD + bodyCols.filter((c) => c.pinned).reduce((n, c) => n + wOf(c), 0)
699
+ const tableWidth = ROWHEAD + visible.reduce((n, c) => n + wOf(c), 0)
700
+ const headHeight = H + (filterRow ? H : 0) + (hasStatusRow ? 28 : 0)
701
+ const nameCol = columns.find((c) => c.locked) ?? visible[0]
702
+ const rowName = (r: SheetRow) => String(r.values[nameCol?.id ?? ""] ?? `row ${rows.indexOf(r) + 1}`)
703
+ const cellCls = dense ? "h-7 px-2" : "h-9 px-3"
704
+
705
+ const range: Rect | null = pos.r < 0 || !lines.length ? null : selAll ? { r1: 0, c1: 0, r2: lines.length - 1, c2: visible.length - 1 } : rectOf(pos, ext)
706
+ const inRange = (r: number, c: number) => !!range && r >= range.r1 && r <= range.r2 && c >= range.c1 && c <= range.c2
707
+ const rangeCells = range ? (range.r2 - range.r1 + 1) * (range.c2 - range.c1 + 1) : 0
708
+
709
+ const cellEl = (r: number, c: number) => grid.current?.querySelector<HTMLElement>(`[data-cell="${r}:${c}"]`)
710
+ const focusCell = React.useCallback(() => cellEl(pos.r, pos.c)?.focus(), [pos])
711
+ // Focus follows the active cell while the person is working in the grid, including after an edit or a menu closes.
712
+ const engaged = React.useRef(false)
713
+ React.useEffect(() => { if (editing === null && renaming === null && menuFor === null && rowMenu === null && engaged.current) focusCell() }, [pos, editing, renaming, menuFor, rowMenu, focusCell])
714
+ React.useEffect(() => { if (ext.r !== pos.r || ext.c !== pos.c) cellEl(ext.r, ext.c)?.scrollIntoView({ block: "nearest", inline: "nearest" }) }, [ext, pos])
715
+ React.useEffect(() => {
716
+ const clamp = (p: Cell) => ({ r: Math.max(top, Math.min(p.r, lines.length - 1)), c: Math.max(0, Math.min(p.c, visible.length - 1)) })
717
+ setPos(clamp)
718
+ setExt(clamp)
719
+ }, [lines.length, visible.length, top])
720
+ // After Add row: scroll to the bottom, where the rows went, and make the first new row's first cell active.
721
+ React.useEffect(() => {
722
+ const before = adding.current
723
+ if (before === null || lines.length <= before) return
724
+ adding.current = null
725
+ engaged.current = true
726
+ const box = boxRef.current
727
+ if (box) box.scrollTop = box.scrollHeight
728
+ const p = { r: before, c: 0 }
729
+ setPos(p); setExt(p); setSelAll(false)
730
+ }, [lines.length])
731
+ React.useEffect(() => {
732
+ const up = () => { dragging.current = false }
733
+ document.addEventListener("mouseup", up)
734
+ return () => document.removeEventListener("mouseup", up)
735
+ }, [])
736
+
737
+ const keyOf = (row: SheetRow, col: SheetColumn) => `${row.id}:${col.id}`
738
+ const clampBody = (p: Cell) => ({ r: Math.max(0, Math.min(lines.length - 1, p.r)), c: Math.max(0, Math.min(visible.length - 1, p.c)) })
739
+ const announceCell = (p: Cell) => {
740
+ const row = rowAt(p.r), col = visible[p.c]
741
+ const why = row && col && rejected[keyOf(row, col)]
742
+ if (why) say(`Not pasted into ${col.label} for ${rowName(row)}: ${why}`, "bad")
743
+ }
744
+ /** Header and filter rows included. */
745
+ const move = (r: number, c: number) => {
746
+ const p = { r: Math.max(top, Math.min(lines.length - 1, r)), c: Math.max(0, Math.min(visible.length - 1, c)) }
747
+ setPos(p); setExt(p); setSelAll(false); announceCell(p)
748
+ }
749
+ const go = (to: Cell) => { const p = clampBody(to); setPos(p); setExt(p); setSelAll(false); announceCell(p) }
750
+ const describe = (R: Rect) => {
751
+ const ls = dataLines(R)
752
+ if (!ls.length) return "No cells selected."
753
+ const nC = R.c2 - R.c1 + 1
754
+ const cols = nC > 1 ? `${visible[R.c1].label} to ${visible[R.c2].label}` : visible[R.c1].label
755
+ const rws = ls.length > 1 ? `rows ${ls[0].n} to ${ls[ls.length - 1].n}` : `row ${ls[0].n}`
756
+ return `${plural(ls.length * nC, "cell")} selected: ${cols}, ${rws}.`
757
+ }
758
+ const extendTo = (to: Cell) => {
759
+ const p = clampBody(to)
760
+ setExt(p); setSelAll(false)
761
+ say(describe(rectOf(pos, p)))
762
+ }
763
+ const edge = (from: Cell, dr: number, dc: number): Cell => {
764
+ const inb = (r: number, c: number) => r >= 0 && r < lines.length && c >= 0 && c < visible.length
765
+ const empty = (r: number, c: number) => { const row = rowAt(r); return !row || isBlank(valueOf(visible[c], row)) }
766
+ let { r, c } = from
767
+ if (!inb(r + dr, c + dc)) return from
768
+ if (!empty(r, c) && !empty(r + dr, c + dc)) { while (inb(r + dr, c + dc) && !empty(r + dr, c + dc)) { r += dr; c += dc } }
769
+ else { r += dr; c += dc; while (inb(r + dr, c + dc) && empty(r, c)) { r += dr; c += dc } }
770
+ return { r, c }
771
+ }
772
+ const nextData = (r: number, d: 1 | -1) => { for (let i = r + d; i >= 0 && i < lines.length; i += d) if (lines[i].kind === "row") return i; return r }
773
+
774
+ /* ----- writing cells, with undo */
775
+ const write = (changes: SheetCellChange[]) => {
776
+ if (!changes.length) return
777
+ if (onCellsChange) onCellsChange(changes)
778
+ else for (const ch of changes) onCellChange(ch.rowId, ch.columnId, (ch.value ?? "") as string | number | boolean)
779
+ }
780
+ const apply = (lbl: string, changes: CellChange[]) => {
781
+ const real = changes.filter((c) => c.before !== c.after && !(isBlank(c.before) && isBlank(c.after)))
782
+ if (!real.length) return 0
783
+ write(real.map((c) => ({ rowId: c.rowId, columnId: c.columnId, value: c.after })))
784
+ const h = history.current
785
+ h.undo.push({ label: lbl, changes: real })
786
+ if (h.undo.length > 100) h.undo.shift()
787
+ h.redo = []
788
+ setRejected((prev) => {
789
+ const gone = real.filter((c) => `${c.rowId}:${c.columnId}` in prev)
790
+ if (!gone.length) return prev
791
+ const next = { ...prev }
792
+ for (const c of gone) delete next[`${c.rowId}:${c.columnId}`]
793
+ return next
794
+ })
795
+ return real.length
796
+ }
797
+ const undo = () => {
798
+ const b = history.current.undo.pop()
799
+ if (!b) return say("Nothing to undo.")
800
+ write(b.changes.map((c) => ({ rowId: c.rowId, columnId: c.columnId, value: c.before })))
801
+ history.current.redo.push(b)
802
+ setRejected({})
803
+ say(`Undid ${b.label}${b.changes.length > 1 ? ` of ${plural(b.changes.length, "cell")}` : ""}.`)
804
+ }
805
+ const redo = () => {
806
+ const b = history.current.redo.pop()
807
+ if (!b) return say("Nothing to redo.")
808
+ write(b.changes.map((c) => ({ rowId: c.rowId, columnId: c.columnId, value: c.after })))
809
+ history.current.undo.push(b)
810
+ say(`Redid ${b.label}${b.changes.length > 1 ? ` of ${plural(b.changes.length, "cell")}` : ""}.`)
811
+ }
812
+ const report = (done: string, n: number, rejects: Reject[], extra = "") => {
813
+ setRejected(Object.fromEntries(rejects.map((x) => [x.key, x.reason])))
814
+ let msg = `${done} ${plural(n, "cell")}.`
815
+ if (rejects.length) msg += ` ${plural(rejects.length, "cell")} left as they were. ${rejects[0].where}: ${rejects[0].reason}${rejects.length > 1 ? " Each one is outlined in red; move to it to read why." : ""}`
816
+ msg += extra
817
+ say(msg, rejects.length || extra ? "bad" : "plain")
818
+ }
819
+
820
+ const tsvOfRange = () => {
821
+ if (!range) return ""
822
+ return toTsv(dataLines(range).map(({ row }) => visible.slice(range.c1, range.c2 + 1).map((col) => copyText(col, row))))
823
+ }
824
+ const clearRange = (lbl = "clear") => {
825
+ if (!range) return 0
826
+ const changes: CellChange[] = []
827
+ let kept = 0
828
+ for (const { row } of dataLines(range)) for (const col of visible.slice(range.c1, range.c2 + 1)) {
829
+ if (isComputed(col) || col.notApplicable?.(row)) { kept++; continue }
830
+ changes.push({ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: col.type === "boolean" ? false : "" })
831
+ }
832
+ const n = apply(lbl, changes)
833
+ if (lbl === "clear") {
834
+ const row = rowAt(pos.r), col = visible[pos.c]
835
+ if (rangeCells === 1 && row && col) say(n ? `Cleared ${col.label} for ${rowName(row)}.` : kept ? `${col.label} is ${computedHow(col)}, so it can't be cleared.` : "Nothing to clear.")
836
+ else say(`Cleared ${plural(n, "cell")}.${kept ? ` ${plural(kept, "calculated or not applicable cell")} kept.` : ""}`)
837
+ }
838
+ return n
839
+ }
840
+ const afterCopy = (kind: "copy" | "cut") => {
841
+ if (!range) return
842
+ const ls = dataLines(range), nC = range.c2 - range.c1 + 1
843
+ if (kind === "cut") { const n = clearRange("cut"); say(`Cut ${plural(n, "cell")}. Paste them where you want them.`) }
844
+ else say(`Copied ${plural(ls.length * nC, "cell")}. Paste here, or into Excel or Google Sheets.`)
845
+ }
846
+ const pasteText = (text: string) => {
847
+ const R = range
848
+ if (!R) return
849
+ const data = parseTsv(text)
850
+ const h = data.length, w = Math.max(...data.map((r) => r.length))
851
+ const targets: { r: number; row: SheetRow; n: number }[] = []
852
+ for (let r = R.r1; r < lines.length; r++) { const l = lines[r]; if (l.kind === "row") targets.push({ r, row: l.row, n: l.n }) }
853
+ const selRows = dataLines(R).length, selCols = R.c2 - R.c1 + 1
854
+ let outH = h, outW = w
855
+ // One value into a bigger selection fills it; a block that divides the selection evenly repeats, as in Excel.
856
+ if ((h === 1 && w === 1) || (selRows % h === 0 && selCols % w === 0)) { outH = Math.max(h, selRows); outW = Math.max(w, selCols) }
857
+ const changes: CellChange[] = []
858
+ const rejects: Reject[] = []
859
+ let lost = 0
860
+ for (let i = 0; i < outH; i++) {
861
+ for (let j = 0; j < outW; j++) {
862
+ const t = targets[i], c = R.c1 + j
863
+ if (!t || c >= visible.length) { lost++; continue }
864
+ const col = visible[c]
865
+ const res = coerce(col, data[i % h]?.[j % w] ?? "", t.row)
866
+ if ("reason" in res) rejects.push({ key: keyOf(t.row, col), where: `${col.label}, row ${t.n}`, reason: res.reason })
867
+ else changes.push({ rowId: t.row.id, columnId: col.id, before: valueOf(col, t.row), after: res.value })
868
+ }
869
+ }
870
+ apply("paste", changes)
871
+ if (targets.length) {
872
+ const last = targets[Math.min(outH, targets.length) - 1]
873
+ setPos({ r: targets[0].r, c: R.c1 }); setExt({ r: last.r, c: Math.min(visible.length - 1, R.c1 + outW - 1) }); setSelAll(false)
874
+ }
875
+ report("Pasted", changes.length, rejects, lost ? ` ${plural(lost, "value")} didn't fit: the sheet ends at row ${targets[targets.length - 1]?.n ?? 0}, column ${visible[visible.length - 1]?.label}. Add rows, then paste again.` : "")
876
+ }
877
+ const fill = (dir: "down" | "right") => {
878
+ const R = range
879
+ if (!R) return
880
+ const ls = dataLines(R)
881
+ const changes: CellChange[] = []
882
+ const rejects: Reject[] = []
883
+ let kept = 0
884
+ const put = (col: SheetColumn, row: SheetRow, n: number, text: string) => {
885
+ if (isComputed(col)) { kept++; return }
886
+ const res = coerce(col, text, row)
887
+ if ("reason" in res) rejects.push({ key: keyOf(row, col), where: `${col.label}, row ${n}`, reason: res.reason })
888
+ else changes.push({ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: res.value })
889
+ }
890
+ if (dir === "down") {
891
+ let src: SheetRow | null = null
892
+ let targets = ls
893
+ if (ls.length <= 1) { const above = nextData(R.r1, -1); src = above !== R.r1 ? rowAt(above) : null }
894
+ else { src = ls[0].row; targets = ls.slice(1) }
895
+ if (!src) return say("Nothing above to fill down from.")
896
+ for (let c = R.c1; c <= R.c2; c++) for (const t of targets) put(visible[c], t.row, t.n, copyText(visible[c], src))
897
+ } else {
898
+ const from = R.c1 === R.c2 ? R.c1 - 1 : R.c1
899
+ if (from < 0) return say("Nothing to the left to fill right from.")
900
+ for (const t of ls) for (let c = R.c1 === R.c2 ? R.c1 : R.c1 + 1; c <= R.c2; c++) put(visible[c], t.row, t.n, copyText(visible[from], t.row))
901
+ }
902
+ apply(dir === "down" ? "fill down" : "fill right", changes)
903
+ report(dir === "down" ? "Filled down" : "Filled right", changes.length, rejects, kept ? ` ${plural(kept, "calculated cell")} kept.` : "")
904
+ }
905
+
906
+ // Copy, cut and paste use the browser's own clipboard events, so they work with the keyboard and the Edit menu.
907
+ const clipHandlers = React.useRef({ copy: (_e: ClipboardEvent, _k: "copy" | "cut") => {}, paste: (_e: ClipboardEvent) => {} })
908
+ clipHandlers.current = {
909
+ copy: (e, kind) => {
910
+ if (!range || editing !== null) return
911
+ e.preventDefault()
912
+ e.clipboardData?.setData("text/plain", tsvOfRange())
913
+ if (clip.current) clip.current.handled = true
914
+ afterCopy(kind)
915
+ },
916
+ paste: (e) => {
917
+ if (!range || editing !== null) return
918
+ e.preventDefault()
919
+ if (clip.current) clip.current.handled = true
920
+ pasteText(e.clipboardData?.getData("text/plain") ?? "")
921
+ },
922
+ }
923
+ React.useEffect(() => {
924
+ const inGrid = () => { const a = document.activeElement; return !!a && !!grid.current?.contains(a) && !a.closest("input, textarea, select") }
925
+ const onCopy = (e: ClipboardEvent) => { if (inGrid()) clipHandlers.current.copy(e, "copy") }
926
+ const onCut = (e: ClipboardEvent) => { if (inGrid()) clipHandlers.current.copy(e, "cut") }
927
+ const onPaste = (e: ClipboardEvent) => { if (inGrid()) clipHandlers.current.paste(e) }
928
+ document.addEventListener("copy", onCopy)
929
+ document.addEventListener("cut", onCut)
930
+ document.addEventListener("paste", onPaste)
931
+ return () => { document.removeEventListener("copy", onCopy); document.removeEventListener("cut", onCut); document.removeEventListener("paste", onPaste) }
932
+ }, [])
933
+ /** If the browser doesn't raise its own clipboard event for the shortcut, use the Clipboard API instead. */
934
+ const clipboardFallback = (kind: "copy" | "cut" | "paste") => {
935
+ clip.current = { kind, handled: false }
936
+ window.setTimeout(() => {
937
+ const c = clip.current
938
+ clip.current = null
939
+ if (!c || c.handled) return
940
+ if (kind === "paste") navigator.clipboard?.readText().then(pasteText).catch(() => say(`Couldn't read the clipboard. Press ${M}V again, or allow clipboard access.`, "bad"))
941
+ else navigator.clipboard?.writeText(tsvOfRange()).then(() => afterCopy(kind)).catch(() => say(`Couldn't ${kind} to the clipboard. Press ${M}${kind === "cut" ? "X" : "C"} again, or allow clipboard access.`, "bad"))
942
+ }, 60)
943
+ }
944
+
945
+ const patch = (id: string, p: Partial<SheetColumn>) => onColumnsChange(columns.map((c) => (c.id === id ? { ...c, ...p } : c)))
946
+ const insertAt = (index: number) => {
947
+ if (onInsertColumn) return onInsertColumn(index)
948
+ const id = `col-${Date.now().toString(36)}`
949
+ const next = [...columns]
950
+ next.splice(index, 0, { id, label: "New column", type: "text", width: 160 })
951
+ onColumnsChange(next)
952
+ renameNext.current = true
953
+ setRenaming(id)
954
+ }
955
+ const remove = (col: SheetColumn) => {
956
+ const before = columns
957
+ onColumnsChange(columns.filter((c) => c.id !== col.id))
958
+ toast(`Deleted column “${col.label}”`, { description: "Its values are kept until you leave the table.", action: { label: "Undo", onClick: () => onColumnsChange(before) } })
959
+ }
960
+ const togglePin = (col: SheetColumn) => {
961
+ patch(col.id, { pinned: !col.pinned })
962
+ setStatus(col.pinned ? `Unpinned ${col.label}.` : `Pinned ${col.label}. It stays in view when you scroll sideways.`)
963
+ }
964
+ const MIN_W = 60, MAX_W = 640
965
+ const clampW = (w: number) => Math.round(Math.max(MIN_W, Math.min(MAX_W, w)))
966
+ const setWidth = (col: SheetColumn, w: number) => { const next = clampW(w); patch(col.id, { width: next }); setStatus(`${col.label} is ${next}px wide.`) }
967
+ /** Fit to the widest value: measured with the grid's own font, plus room for the header's icons. */
968
+ const fitWidth = (col: SheetColumn) => {
969
+ const ctx = document.createElement("canvas").getContext("2d")
970
+ const el = grid.current
971
+ if (!ctx || !el) return
972
+ const cs = getComputedStyle(el)
973
+ ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
974
+ const pad = (dense ? 16 : 24) + (col.type === "select" ? 18 : col.type === "boolean" ? 40 : 0)
975
+ let w = ctx.measureText(col.label).width + (dense ? 16 : 24) + 44
976
+ for (const row of rows) w = Math.max(w, ctx.measureText(copyText(col, row)).width + pad)
977
+ setWidth(col, Math.min(w, 480))
978
+ }
979
+ const startResize = (e: React.PointerEvent, col: SheetColumn) => {
980
+ if (e.button !== 0) return
981
+ e.preventDefault()
982
+ e.stopPropagation()
983
+ const x0 = e.clientX, w0 = col.width
984
+ const target = e.currentTarget as HTMLElement
985
+ target.setPointerCapture(e.pointerId)
986
+ let w = w0
987
+ const onMove = (ev: PointerEvent) => { w = clampW(w0 + ev.clientX - x0); setDrag({ id: col.id, w }) }
988
+ const onUp = () => {
989
+ target.removeEventListener("pointermove", onMove)
990
+ target.removeEventListener("pointerup", onUp)
991
+ target.removeEventListener("pointercancel", onUp)
992
+ setDrag(null)
993
+ if (w !== w0) setWidth(col, w)
994
+ }
995
+ target.addEventListener("pointermove", onMove)
996
+ target.addEventListener("pointerup", onUp)
997
+ target.addEventListener("pointercancel", onUp)
998
+ }
999
+ const toggleGroup = (key: string) => {
1000
+ const g = lines.find((l) => l.kind === "group" && l.key === key)
1001
+ setCollapsed((s) => { const n = new Set(s); if (n.has(key)) n.delete(key); else n.add(key); return n })
1002
+ if (g?.kind === "group") say(`${g.label} ${g.open ? `collapsed, ${plural(g.rows.length, "row")} hidden` : "expanded"}.`)
1003
+ }
1004
+ const toggleBool = (row: SheetRow, col: SheetColumn) => {
1005
+ if (col.notApplicable?.(row)) return say(`${col.label} doesn't apply to ${rowName(row)}.`)
1006
+ const next = !valueOf(col, row)
1007
+ apply("edit", [{ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: next }])
1008
+ say(`${col.label} for ${rowName(row)}: ${next ? col.onLabel ?? "Yes" : col.offLabel ?? "No"}.`)
1009
+ }
1010
+ const setSelected = (ids: Set<string>) => onSelectedRowIdsChange?.([...ids])
1011
+ const toggleRows = (rs: SheetRow[]) => {
1012
+ if (!selectable || !rs.length) return
1013
+ const all = rs.every((r) => selection.has(r.id))
1014
+ const next = new Set(selection)
1015
+ for (const r of rs) if (all) next.delete(r.id); else next.add(r.id)
1016
+ setSelected(next)
1017
+ say(`${all ? "Deselected" : "Selected"} ${plural(rs.length, "row")}. ${plural(next.size, "row")} selected in all.`)
1018
+ }
1019
+
1020
+ const startEdit = (initial?: string) => {
1021
+ const col = visible[pos.c], line = lines[pos.r]
1022
+ if (!col || !line) return
1023
+ if (line.kind === "group") return toggleGroup(line.key)
1024
+ const row = line.row
1025
+ if (col.notApplicable?.(row)) return say(`${col.label} doesn't apply to ${rowName(row)}.`)
1026
+ if (col.type === "boolean") return toggleBool(row, col)
1027
+ if (isComputed(col)) {
1028
+ const cell = row.computed?.[col.id]
1029
+ if (cell?.state === "failed" && onRetryCell) {
1030
+ onRetryCell(row.id, col.id)
1031
+ setStatus(`Running ${col.label} again for ${rowName(row)}${col.costPerRow ? `, about ${formatCredits(col.costPerRow)}` : ""}.`)
1032
+ return
1033
+ }
1034
+ setStatus(`${col.label} is ${computedHow(col)}, so it can't be typed in.${onEditColumn ? " Open the column menu to edit how it's made." : ""}`)
1035
+ return
1036
+ }
1037
+ setError("")
1038
+ setEditing(initial ?? String(row.values[col.id] ?? ""))
1039
+ }
1040
+ const commit = (then: "stay" | "next" | "prev" | "down" | "up", raw?: string) => {
1041
+ const col = visible[pos.c], row = rowAt(pos.r)
1042
+ if (!col || !row) { setEditing(null); return false }
1043
+ const res = coerce(col, raw ?? editing ?? "", row)
1044
+ if ("reason" in res) { setError(res.reason); say(`${col.label}: ${res.reason}`, "bad"); return false }
1045
+ apply("edit", [{ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: res.value }])
1046
+ setError("")
1047
+ setStatus(`Saved ${col.label} for ${rowName(row)}.`)
1048
+ setEditing(null)
1049
+ if (then === "next") go({ r: pos.r, c: pos.c + 1 })
1050
+ else if (then === "prev") go({ r: pos.r, c: pos.c - 1 })
1051
+ else if (then === "down") go({ r: nextData(pos.r, 1), c: pos.c })
1052
+ else if (then === "up") go({ r: nextData(pos.r, -1), c: pos.c })
1053
+ return true
1054
+ }
1055
+ /** ⌘Enter: the typed value goes into every selected cell. */
1056
+ const fillSelection = () => {
1057
+ if (!range || editing === null) return
1058
+ const text = editing
1059
+ const changes: CellChange[] = []
1060
+ const rejects: Reject[] = []
1061
+ for (const t of dataLines(range)) for (const col of visible.slice(range.c1, range.c2 + 1)) {
1062
+ const res = coerce(col, text, t.row)
1063
+ if ("reason" in res) rejects.push({ key: keyOf(t.row, col), where: `${col.label}, row ${t.n}`, reason: res.reason })
1064
+ else changes.push({ rowId: t.row.id, columnId: col.id, before: valueOf(col, t.row), after: res.value })
1065
+ }
1066
+ apply("fill", changes)
1067
+ setEditing(null)
1068
+ setError("")
1069
+ report("Filled", changes.length, rejects)
1070
+ }
1071
+ const cancel = () => { setEditing(null); setError(""); setStatus("Edit cancelled.") }
1072
+
1073
+ const onFilterKey = (e: React.KeyboardEvent, input: HTMLInputElement) => {
1074
+ const c = Number(input.dataset.filter)
1075
+ const col = visible[c]
1076
+ if (e.key === "ArrowDown" || e.key === "Enter") { e.preventDefault(); move(0, c) }
1077
+ else if (e.key === "ArrowUp") { e.preventDefault(); move(top, c) }
1078
+ else if (e.key === "Escape" && input.value) { e.preventDefault(); e.stopPropagation(); setFilters((f) => ({ ...f, [col.id]: "" })); say(`Cleared the ${col.label} filter.`) }
1079
+ else if (e.key === "ArrowLeft" && input.selectionStart === 0 && input.selectionEnd === 0 && c > 0) { e.preventDefault(); move(-1, c - 1) }
1080
+ else if (e.key === "ArrowRight" && input.selectionStart === input.value.length && c < visible.length - 1) { e.preventDefault(); move(-1, c + 1) }
1081
+ }
1082
+
1083
+ const onKey = (e: React.KeyboardEvent) => {
1084
+ const t = e.target as HTMLElement
1085
+ if (renaming !== null) return
1086
+ if (t.closest("[role=menu]")) return
1087
+ if (t.dataset.filter !== undefined) return onFilterKey(e, t as HTMLInputElement)
1088
+ if (editing !== null) {
1089
+ if (visible[pos.c]?.type === "select") return
1090
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancel() }
1091
+ else if (e.key === "Enter") { e.preventDefault(); if (mod(e)) fillSelection(); else commit(e.shiftKey ? "up" : "down") }
1092
+ else if (e.key === "Tab") { e.preventDefault(); commit(e.shiftKey ? "prev" : "next") }
1093
+ return
1094
+ }
1095
+ const k = e.key, m = mod(e), sh = e.shiftKey
1096
+ const header = pos.r < 0
1097
+ const arrow = ({ ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] } as Record<string, [number, number]>)[k]
1098
+ if (arrow && !e.altKey) {
1099
+ e.preventDefault()
1100
+ const [dr, dc] = arrow
1101
+ if (header) return move(pos.r + dr, pos.c + dc)
1102
+ if (m) { const to = edge(sh ? ext : pos, dr, dc); return sh ? extendTo(to) : go(to) }
1103
+ if (sh) return extendTo({ r: ext.r + dr, c: ext.c + dc })
1104
+ return move(pos.r + dr, pos.c + dc)
1105
+ }
1106
+ if (k === "Home" || k === "End") {
1107
+ e.preventDefault()
1108
+ const c = k === "Home" ? 0 : visible.length - 1
1109
+ if (header) return move(pos.r, c)
1110
+ const r = m ? (k === "Home" ? 0 : lines.length - 1) : sh ? ext.r : pos.r
1111
+ return sh ? extendTo({ r, c }) : go({ r, c })
1112
+ }
1113
+ if (k === "PageDown" || k === "PageUp") {
1114
+ e.preventDefault()
1115
+ const d = k === "PageDown" ? 10 : -10
1116
+ if (header) return d > 0 ? go({ r: 0, c: pos.c }) : undefined
1117
+ return sh ? extendTo({ r: ext.r + d, c: ext.c }) : go({ r: pos.r + d, c: pos.c })
1118
+ }
1119
+ if (header) {
1120
+ if (e.altKey && sh && (k === "ArrowRight" || k === "ArrowLeft")) { e.preventDefault(); const col = visible[pos.c]; return setWidth(col, col.width + (k === "ArrowRight" ? 16 : -16)) }
1121
+ if ((k === "ArrowDown" && e.altKey) || k === "F2") { e.preventDefault(); setMenuFor(visible[pos.c].id) }
1122
+ // Enter and Space open the menu through the trigger itself.
1123
+ return
1124
+ }
1125
+ if (e.ctrlKey && !e.metaKey && !e.altKey && k.toLowerCase() === "y") { e.preventDefault(); return redo() }
1126
+ if (m && !e.altKey) {
1127
+ const low = k.toLowerCase()
1128
+ if (low === "c" || low === "x" || low === "v") return clipboardFallback(low === "c" ? "copy" : low === "x" ? "cut" : "paste")
1129
+ if (low === "a") { e.preventDefault(); setSelAll(true); return say(`Selected all ${plural(dataLines({ r1: 0, c1: 0, r2: lines.length - 1, c2: 0 }).length * visible.length, "cell")}.`) }
1130
+ if (low === "d") { e.preventDefault(); return fill("down") }
1131
+ if (low === "r") { e.preventDefault(); return fill("right") }
1132
+ if (low === "z") { e.preventDefault(); return sh ? redo() : undo() }
1133
+ // As in Google Sheets: ⌘/ lists the shortcuts, ⌘; puts today's date in a date cell.
1134
+ if (k === "/") { e.preventDefault(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "?" })); return }
1135
+ if (k === ";") {
1136
+ e.preventDefault()
1137
+ const col = visible[pos.c], row = rowAt(pos.r)
1138
+ if (!col || !row || col.type !== "date" || col.notApplicable?.(row)) return say("⌘; puts today's date in a date cell.")
1139
+ const today = new Date().toISOString().slice(0, 10)
1140
+ apply("edit", [{ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: today }])
1141
+ return say(`${col.label} for ${rowName(row)}: today.`)
1142
+ }
1143
+ return
1144
+ }
1145
+ if (k === "Escape" && rangeCells > 1) { e.preventDefault(); e.stopPropagation(); go(pos); return say("Selection cleared.") }
1146
+ if (k === " " && sh) { e.preventDefault(); return toggleRows(range ? dataLines(range).map((l) => l.row) : []) }
1147
+ if (k === " " && e.ctrlKey) { e.preventDefault(); setPos({ r: 0, c: pos.c }); setExt({ r: lines.length - 1, c: pos.c }); setSelAll(false); return say(`Selected the ${visible[pos.c].label} column.`) }
1148
+ if ((k === "F10" && sh) || k === "ContextMenu") { const row = rowAt(pos.r); if (row && hasRowMenu) { e.preventDefault(); setRowMenu(row.id) } return }
1149
+ const line = lines[pos.r]
1150
+ if (line?.kind === "group") {
1151
+ if (k === "Enter" || k === " " || k === "F2") { e.preventDefault(); toggleGroup(line.key) }
1152
+ return
1153
+ }
1154
+ const col = visible[pos.c], row = rowAt(pos.r)
1155
+ if (!col || !row) return
1156
+ if (col.type === "boolean" && (k === "Enter" || k === " ")) { e.preventDefault(); return toggleBool(row, col) }
1157
+ if (k === "Enter" || k === "F2") { e.preventDefault(); startEdit() }
1158
+ else if (k === "Delete" || k === "Backspace") { e.preventDefault(); clearRange() }
1159
+ else if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && !isComputed(col) && !col.notApplicable?.(row) && col.type === "select") { e.preventDefault(); startEdit() }
1160
+ else if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && !isComputed(col) && !col.notApplicable?.(row) && !["date", "boolean"].includes(col.type)) { e.preventDefault(); startEdit(k) }
1161
+ }
1162
+
1163
+ const pinStyle = (i: number) => (bodyCols[i]?.pinned ? ({ "--l": `${lefts[i]}px` } as React.CSSProperties) : undefined)
1164
+
1165
+ const headerMenu = (col: SheetColumn, i: number) => {
1166
+ const full = columns.indexOf(col)
1167
+ const [asc, desc] = SORT_WORDS[col.type] ?? ["Sort A to Z", "Sort Z to A"]
1168
+ const sorted = sort?.columnId === col.id ? sort.direction : null
1169
+ return (
1170
+ <DropdownMenuContent
1171
+ align="start"
1172
+ className="w-60"
1173
+ onCloseAutoFocus={(e) => { if (renameNext.current) e.preventDefault() }}
1174
+ >
1175
+ <DropdownMenuLabel className="flex items-center gap-control text-ink-2"><ColumnTypeIcon type={col.type} provider={col.provider} />{COLUMN_TYPES[col.type].label} column{col.costPerRow ? ` · ${formatCredits(col.costPerRow)} / row` : ""}</DropdownMenuLabel>
1176
+ {col.formula && <p className="truncate px-2 pb-2 font-mono text-sm text-ink-2">{col.formula}</p>}
1177
+ <DropdownMenuItem onSelect={() => { renameNext.current = true; setRenaming(col.id) }}><Pencil />Rename</DropdownMenuItem>
1178
+ {isComputed(col) ? (
1179
+ <>
1180
+ {onEditColumn && <DropdownMenuItem onSelect={() => onEditColumn(col.id)}><Settings2 />Edit column</DropdownMenuItem>}
1181
+ {onRetryColumn && !!col.status?.errors && <DropdownMenuItem onSelect={() => onRetryColumn(col.id)}><RefreshCw />Run {col.status.errors} failed {col.status.errors === 1 ? "cell" : "cells"} again{col.costPerRow ? ` · ≈ ${formatCredits(col.costPerRow * col.status.errors)}` : ""}</DropdownMenuItem>}
1182
+ </>
1183
+ ) : (
1184
+ <DropdownMenuSub>
1185
+ <DropdownMenuSubTrigger><ColumnTypeIcon type={col.type} />Change type</DropdownMenuSubTrigger>
1186
+ <DropdownMenuSubContent className="w-44">
1187
+ <DropdownMenuRadioGroup value={col.type} onValueChange={(v) => { patch(col.id, { type: v as ColumnType }); setStatus(`${col.label} is now a ${COLUMN_TYPES[v as ColumnType].label.toLowerCase()} column. Values that don't fit are kept as text.`) }}>
1188
+ {(["text", "number", "date", "select", "boolean"] as const).map((t) => <DropdownMenuRadioItem key={t} value={t}>{COLUMN_TYPES[t].label}</DropdownMenuRadioItem>)}
1189
+ </DropdownMenuRadioGroup>
1190
+ </DropdownMenuSubContent>
1191
+ </DropdownMenuSub>
1192
+ )}
1193
+ {onSortChange && (
1194
+ <>
1195
+ <DropdownMenuSeparator />
1196
+ <DropdownMenuItem onSelect={() => onSortChange({ columnId: col.id, direction: "asc" })}><ArrowUp />{asc}{sorted === "asc" && <Check className="ml-auto" />}</DropdownMenuItem>
1197
+ <DropdownMenuItem onSelect={() => onSortChange({ columnId: col.id, direction: "desc" })}><ArrowDown />{desc}{sorted === "desc" && <Check className="ml-auto" />}</DropdownMenuItem>
1198
+ {sorted && <DropdownMenuItem onSelect={() => onSortChange(null)}><ArrowUpDown />Clear sort</DropdownMenuItem>}
1199
+ </>
1200
+ )}
1201
+ {onFilterColumn && <DropdownMenuItem onSelect={() => onFilterColumn(col.id)}><ListFilter />Filter by this column</DropdownMenuItem>}
1202
+ <DropdownMenuSeparator />
1203
+ <DropdownMenuItem role="menuitemcheckbox" aria-checked={!!col.wrap} onSelect={(e) => { e.preventDefault(); patch(col.id, { wrap: !col.wrap }) }}><WrapText />Wrap text{col.wrap && <Check className="ml-auto" />}</DropdownMenuItem>
1204
+ <DropdownMenuItem onSelect={() => togglePin(col)}>{col.pinned ? <><PinOff />Unpin column</> : <><Pin />Pin column</>}</DropdownMenuItem>
1205
+ <DropdownMenuSub>
1206
+ <DropdownMenuSubTrigger><MoveHorizontal />Column width</DropdownMenuSubTrigger>
1207
+ <DropdownMenuSubContent className="w-52">
1208
+ <DropdownMenuItem onSelect={(e) => { e.preventDefault(); setWidth(col, col.width + 40) }}><UnfoldHorizontal />Wider<DropdownMenuShortcut>{isMac ? "⌥⇧→" : "Alt+Shift+→"}</DropdownMenuShortcut></DropdownMenuItem>
1209
+ <DropdownMenuItem onSelect={(e) => { e.preventDefault(); setWidth(col, col.width - 40) }}><FoldHorizontal />Narrower<DropdownMenuShortcut>{isMac ? "⌥⇧←" : "Alt+Shift+←"}</DropdownMenuShortcut></DropdownMenuItem>
1210
+ <DropdownMenuItem onSelect={() => fitWidth(col)}><MoveHorizontal />Fit to content</DropdownMenuItem>
1211
+ </DropdownMenuSubContent>
1212
+ </DropdownMenuSub>
1213
+ {!col.locked && <DropdownMenuItem onSelect={() => { patch(col.id, { hidden: true }); setStatus(`Hid ${col.label}. Show it again from Columns.`); move(pos.r, Math.min(i, visible.length - 2)) }}><EyeOff />Hide column</DropdownMenuItem>}
1214
+ <DropdownMenuSeparator />
1215
+ <DropdownMenuItem onSelect={() => insertAt(full)}><ArrowLeftToLine />Insert column left</DropdownMenuItem>
1216
+ <DropdownMenuItem onSelect={() => insertAt(full + 1)}><ArrowRightToLine />Insert column right</DropdownMenuItem>
1217
+ {!col.locked && (
1218
+ <>
1219
+ <DropdownMenuSeparator />
1220
+ <DropdownMenuItem variant="destructive" onSelect={() => remove(col)}><Trash2 />Delete column</DropdownMenuItem>
1221
+ </>
1222
+ )}
1223
+ </DropdownMenuContent>
1224
+ )
1225
+ }
1226
+
1227
+ const renderValue = (col: SheetColumn, row: SheetRow) => {
1228
+ if (col.notApplicable?.(row)) return <span aria-label="not applicable" className="text-sm text-ink-3">n/a</span>
1229
+ if (isComputed(col)) return <ComputedCell col={col} cell={row.computed?.[col.id]} wrap={col.wrap} />
1230
+ const v = row.values[col.id]
1231
+ if (col.type === "boolean")
1232
+ return (
1233
+ <span className="inline-flex items-center gap-2">
1234
+ <Switch size="sm" tabIndex={-1} checked={!!v} aria-label={`${col.label} for ${rowName(row)}`} onCheckedChange={() => toggleBool(row, col)} />
1235
+ <span className={cn("truncate", v ? "text-ink" : "text-ink-3")}>{v ? col.onLabel ?? "Yes" : col.offLabel ?? "No"}</span>
1236
+ </span>
1237
+ )
1238
+ if (col.render) return col.render(v, row)
1239
+ // A select reads as a selector: the value and a chevron that opens the list. Click the active cell, the chevron, or press Enter.
1240
+ if (col.type === "select")
1241
+ return (
1242
+ <span className="flex min-w-0 items-center gap-1">
1243
+ <span className={cn("min-w-0 flex-1 truncate", isBlank(v) && "text-ink-3")}>{isBlank(v) ? <span className="opacity-0 group-hover/cell:opacity-100">Choose</span> : String(v)}</span>
1244
+ <span data-cell-open aria-hidden="true" className="-mr-1 grid size-4 shrink-0 cursor-pointer place-items-center rounded-xs text-ink-3 opacity-0 group-hover/row:opacity-100 group-focus/cell:opacity-100 hover:bg-selected hover:text-ink pointer-coarse:opacity-100">
1245
+ <ChevronDown className="size-3" />
1246
+ </span>
1247
+ </span>
1248
+ )
1249
+ if (isBlank(v)) return null
1250
+ const text = col.type === "number" && typeof v === "number" ? fmtFor(col, v) : col.type === "date" ? fmtDate(String(v)) : String(v)
1251
+ return <span className={cn("block", col.wrap ? "whitespace-normal break-words" : "truncate")}>{text}</span>
1252
+ }
1253
+
1254
+ const editor = (col: SheetColumn, row: SheetRow) => {
1255
+ if (col.type === "select")
1256
+ return (
1257
+ <Select defaultOpen value={editing ?? ""} onValueChange={(v) => commit("stay", v)} onOpenChange={(o) => { if (!o) setEditing((cur) => { if (cur !== null) setStatus("Edit cancelled."); return null }) }}>
1258
+ {/* The cell is the trigger: its 2px outline already marks it, so the trigger draws no border, shadow or ring of its own. */}
1259
+ <SelectTrigger size="sm" aria-label={`${col.label} for ${rowName(row)}`} className="w-full gap-1 rounded-none border-0 bg-transparent p-0 shadow-none hover:border-0 focus-visible:ring-0 data-[size=sm]:h-full"><SelectValue placeholder="Choose" /></SelectTrigger>
1260
+ {/* Opens below the cell, never over it, so the row being changed stays in view. */}
1261
+ <SelectContent position="popper" align="start" sideOffset={4}>{(col.options ?? []).map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}</SelectContent>
1262
+ </Select>
1263
+ )
1264
+ return (
1265
+ <input
1266
+ autoFocus
1267
+ type={col.type === "date" ? "date" : "text"}
1268
+ value={editing ?? ""}
1269
+ aria-label={`${col.label} for ${rowName(row)}`}
1270
+ aria-invalid={!!error || undefined}
1271
+ inputMode={col.type === "number" ? "decimal" : undefined}
1272
+ onChange={(e) => { setEditing(e.target.value); setError("") }}
1273
+ onBlur={() => { if (editing !== null && !commit("stay")) setEditing(null) }}
1274
+ className={cn("h-full w-full bg-transparent outline-none", col.type === "number" && "text-right")}
1275
+ />
1276
+ )
1277
+ }
1278
+
1279
+ // Sum, average and count of the selected numbers, as in the status bar of Excel and Google Sheets.
1280
+ const rangeStats = (() => {
1281
+ if (!range || rangeCells < 2) return null
1282
+ const ls = dataLines(range)
1283
+ const cols = visible.slice(range.c1, range.c2 + 1)
1284
+ let count = 0
1285
+ const nums: number[] = []
1286
+ for (const { row } of ls) for (const col of cols) {
1287
+ const v = valueOf(col, row)
1288
+ if (isBlank(v) || col.notApplicable?.(row)) continue
1289
+ count++
1290
+ if (col.type === "number" && v !== null && v !== undefined && !Number.isNaN(Number(v))) nums.push(Number(v))
1291
+ }
1292
+ const numCols = cols.filter((c) => c.type === "number")
1293
+ const money = numCols.length && numCols.every((c) => c.currency && c.currency === numCols[0].currency) ? numCols[0] : undefined
1294
+ const sum = nums.reduce((a, b) => a + b, 0)
1295
+ return nums.length ? `Sum ${fmtFor(money, sum)} · Average ${fmtFor(money, Math.round((sum / nums.length) * 100) / 100)} · Count ${count}` : `Count ${count}`
1296
+ })()
1297
+
1298
+ const rowMenuFor = (row: SheetRow) => (
1299
+ <DropdownMenu open={rowMenu === row.id} onOpenChange={(o) => setRowMenu(o ? row.id : null)}>
1300
+ <DropdownMenuTrigger asChild>
1301
+ <Button variant="ghost" size="icon-xs" tabIndex={-1} aria-label={`Actions for ${rowName(row)}`} className="relative size-5 text-ink-3 after:absolute after:-inset-0.5 hover:text-ink" onMouseDown={(e) => e.stopPropagation()}>
1302
+ <EllipsisVertical />
1303
+ </Button>
1304
+ </DropdownMenuTrigger>
1305
+ <DropdownMenuContent align="start" className="w-52" onCloseAutoFocus={(e) => e.preventDefault()}>
1306
+ {onOpenRow && <DropdownMenuItem onSelect={() => onOpenRow(row.id)}><PanelRight />Open</DropdownMenuItem>}
1307
+ {onOpenRow && rowActions && <DropdownMenuSeparator />}
1308
+ {rowActions?.(row)}
1309
+ </DropdownMenuContent>
1310
+ </DropdownMenu>
1311
+ )
1312
+
1313
+ // Body rows are memoised: a key press re-renders only the rows whose cells change. They call back through this
1314
+ // stable object, which always holds the latest handlers.
1315
+ const rowApiRef = React.useRef<SheetRowApi>(null as unknown as SheetRowApi)
1316
+ rowApiRef.current = {
1317
+ mouseDown: (e, r, c, isEditing) => {
1318
+ clickOpens.current = false
1319
+ if (isEditing || e.button !== 0) return
1320
+ if (e.shiftKey && pos.r >= 0) { e.preventDefault(); extendTo({ r, c }); return }
1321
+ // A second click on the active select cell, or a click on its chevron, opens the list.
1322
+ clickOpens.current = visible[c]?.type === "select" && ((pos.r === r && pos.c === c && rangeCells <= 1) || !!(e.target as HTMLElement).closest("[data-cell-open]"))
1323
+ setPos({ r, c }); setExt({ r, c }); setSelAll(false); dragging.current = true
1324
+ },
1325
+ click: () => { if (clickOpens.current) { clickOpens.current = false; startEdit() } },
1326
+ mouseEnter: (e, r, c) => { if (dragging.current && e.buttons === 1 && (ext.r !== r || ext.c !== c)) extendTo({ r, c }) },
1327
+ doubleClick: () => startEdit(),
1328
+ contextMenu: (e, r, c, row) => { if (!hasRowMenu) return; e.preventDefault(); go({ r, c }); setRowMenu(row.id) },
1329
+ renderValue,
1330
+ editor,
1331
+ rowMenu: rowMenuFor,
1332
+ toggleRow: (row) => toggleRows([row]),
1333
+ rowName,
1334
+ openRow: (id) => onOpenRow?.(id),
1335
+ }
1336
+ const rowApi = React.useMemo<SheetRowApi>(() => ({
1337
+ mouseDown: (...a) => rowApiRef.current.mouseDown(...a),
1338
+ click: (...a) => rowApiRef.current.click(...a),
1339
+ mouseEnter: (...a) => rowApiRef.current.mouseEnter(...a),
1340
+ doubleClick: () => rowApiRef.current.doubleClick(),
1341
+ contextMenu: (...a) => rowApiRef.current.contextMenu(...a),
1342
+ renderValue: (...a) => rowApiRef.current.renderValue(...a),
1343
+ editor: (...a) => rowApiRef.current.editor(...a),
1344
+ rowMenu: (...a) => rowApiRef.current.rowMenu(...a),
1345
+ toggleRow: (...a) => rowApiRef.current.toggleRow(...a),
1346
+ rowName: (...a) => rowApiRef.current.rowName(...a),
1347
+ openRow: (...a) => rowApiRef.current.openRow(...a),
1348
+ }), [])
1349
+
1350
+ const shownIds = filteredRows.map((r) => r.id)
1351
+ const allChecked = shownIds.length > 0 && shownIds.every((id) => selection.has(id))
1352
+ const someChecked = shownIds.some((id) => selection.has(id))
1353
+ const headerRows = 1 + (filterRow ? 1 : 0) + (hasStatusRow ? 1 : 0)
1354
+ const pinCls = (i: number, head = false) => bodyCols[i]?.pinned && cn("sticky left-(--l)", head ? "z-30" : "z-1", !bodyCols[i + 1]?.pinned && "border-r")
1355
+
1356
+ // In a workspace the sheet fills its region edge to edge; elsewhere it is a framed card.
1357
+ const inWorkspace = useWorkspace()
1358
+ return (
1359
+ <div
1360
+ data-slot="sheet-grid"
1361
+ data-size={size}
1362
+ onFocus={() => { engaged.current = true }}
1363
+ onBlur={(e) => { const to = e.relatedTarget as HTMLElement | null; if (to && !e.currentTarget.contains(to) && !to.closest("[role=menu], [role=listbox]")) engaged.current = false }}
1364
+ className={cn("flex min-h-0 flex-col overflow-hidden bg-surface", !inWorkspace && "rounded-sm border border-line", className)}>
1365
+ <div
1366
+ ref={boxRef}
1367
+ className="min-h-0 flex-1 overflow-auto"
1368
+ style={{ "--pl": `${pinnedWidth}px`, "--tf": `${H}px`, "--ts": `${H + (filterRow ? H : 0)}px`, "--hh": `${headHeight}px`, "--fh": `${hasTotals ? H : 0}px`, "--rh": `${ROWHEAD}px` } as React.CSSProperties}
1369
+ >
1370
+ <table
1371
+ ref={grid}
1372
+ role="grid"
1373
+ aria-label={label}
1374
+ aria-multiselectable="true"
1375
+ aria-rowcount={lines.length + headerRows + (hasTotals ? 1 : 0)}
1376
+ aria-colcount={visible.length + 1}
1377
+ onKeyDown={onKey}
1378
+ style={{ "--tw": `${tableWidth}px` } as React.CSSProperties}
1379
+ className="w-(--tw) min-w-full table-fixed border-separate border-spacing-0 text-base"
1380
+ >
1381
+ <colgroup>
1382
+ <col className="w-(--rh)" />
1383
+ {visible.map((c) => <col key={c.id} className="w-(--w)" style={{ "--w": `${wOf(c)}px` } as React.CSSProperties} />)}
1384
+ </colgroup>
1385
+ <thead>
1386
+ <tr aria-rowindex={1}>
1387
+ <th scope="col" className={cn("sticky top-0 left-0 z-40 border-r border-b border-line bg-surface-2 px-1 text-left", dense ? "h-7" : "h-9")}>
1388
+ {selectable ? (
1389
+ <span style={{ "--s": `${SLOT}px` } as React.CSSProperties} className="grid w-(--s) place-items-center">
1390
+ <Checkbox tabIndex={-1} aria-label="Select all rows shown" checked={allChecked ? true : someChecked ? "indeterminate" : false} onCheckedChange={() => { const next = new Set(selection); for (const id of shownIds) if (allChecked) next.delete(id); else next.add(id); setSelected(next); say(allChecked ? "Deselected all rows shown." : `Selected ${plural(shownIds.length, "row")}.`) }} />
1391
+ </span>
1392
+ ) : <span className="sr-only">Row</span>}
1393
+ </th>
1394
+ {visible.map((c, i) => {
1395
+ const sorted = sort?.columnId === c.id ? sort.direction : null
1396
+ const here = pos.r === top && pos.c === i
1397
+ return (
1398
+ <th
1399
+ key={c.id}
1400
+ scope="col"
1401
+ aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
1402
+ style={pinStyle(i)}
1403
+ className={cn("group/head sticky top-0 z-20 border-b border-line bg-surface-2 p-0 text-left text-sm font-medium text-ink-2", dense ? "h-7" : "h-9", pinCls(i, true))}
1404
+ >
1405
+ {renaming === c.id ? (
1406
+ <div className="px-1">
1407
+ <Input
1408
+ autoFocus
1409
+ defaultValue={c.label}
1410
+ aria-label={`Rename ${c.label}`}
1411
+ className={cn(dense && "h-6")}
1412
+ onFocus={(e) => e.currentTarget.select()}
1413
+ onKeyDown={(e) => {
1414
+ e.stopPropagation()
1415
+ if (e.key === "Enter") e.currentTarget.blur()
1416
+ if (e.key === "Escape") { e.preventDefault(); e.currentTarget.dataset.cancel = "1"; e.currentTarget.blur() }
1417
+ }}
1418
+ onBlur={(e) => {
1419
+ const name = e.currentTarget.value.trim()
1420
+ if (!e.currentTarget.dataset.cancel && name && name !== c.label) { patch(c.id, { label: name }); setStatus(`Renamed to ${name}.`) }
1421
+ renameNext.current = false
1422
+ setRenaming(null)
1423
+ setPos({ r: top, c: Math.max(0, visible.findIndex((x) => x.id === c.id)) })
1424
+ window.setTimeout(() => grid.current?.querySelector<HTMLElement>(`[data-col="${c.id}"]`)?.focus())
1425
+ }}
1426
+ />
1427
+ </div>
1428
+ ) : (
1429
+ <DropdownMenu open={menuFor === c.id} onOpenChange={(o) => setMenuFor(o ? c.id : null)}>
1430
+ <DropdownMenuTrigger asChild>
1431
+ <button
1432
+ type="button"
1433
+ data-cell={`${top}:${i}`}
1434
+ data-col={c.id}
1435
+ tabIndex={here && editing === null ? 0 : -1}
1436
+ aria-haspopup="menu"
1437
+ onMouseDown={() => { setPos({ r: top, c: i }); setExt({ r: top, c: i }) }}
1438
+ onKeyDown={(e) => { if (e.key.startsWith("Arrow") && !e.altKey) e.preventDefault() }}
1439
+ className={cn("flex w-full items-center gap-control text-left hover:bg-hover", dense ? "h-7 px-2" : "h-9 px-3", here ? "outline-2 -outline-offset-2 outline-solid outline-primary" : "outline-none", pos.r >= 0 && pos.c === i && "bg-primary-soft text-primary-ink")}
1440
+ >
1441
+ <ColumnTypeIcon type={c.type} provider={c.provider} />
1442
+ <span className={cn("min-w-0 flex-1 truncate", c.type === "number" && "text-right")}>{c.label}</span>
1443
+ {c.pinned && <Pin className="size-3 shrink-0 text-ink-3" aria-label="Pinned" />}
1444
+ {sorted && (sorted === "asc" ? <ArrowUp className="size-3 shrink-0 text-ink-3" aria-hidden="true" /> : <ArrowDown className="size-3 shrink-0 text-ink-3" aria-hidden="true" />)}
1445
+ <ChevronDown className="size-3 shrink-0 text-ink-3" aria-hidden="true" />
1446
+ </button>
1447
+ </DropdownMenuTrigger>
1448
+ {headerMenu(c, i)}
1449
+ </DropdownMenu>
1450
+ )}
1451
+ {/* Drag to resize, double-click to fit. Keyboard: ⌥⇧ and an arrow on the header, or Column width in its menu. */}
1452
+ <span
1453
+ aria-hidden="true"
1454
+ title="Drag to resize, double-click to fit"
1455
+ onPointerDown={(e) => startResize(e, c)}
1456
+ onDoubleClick={(e) => { e.stopPropagation(); fitWidth(c) }}
1457
+ className={cn(
1458
+ "absolute top-0 right-0 z-10 h-full w-2 cursor-col-resize touch-none",
1459
+ "after:absolute after:inset-y-1 after:right-0 after:w-0.5 after:rounded-full after:bg-primary after:opacity-0 after:transition-opacity after:duration-(--duration-fast) hover:after:opacity-100",
1460
+ drag?.id === c.id && "after:opacity-100"
1461
+ )}
1462
+ />
1463
+ </th>
1464
+ )
1465
+ })}
1466
+ </tr>
1467
+ {filterRow && (
1468
+ <tr aria-rowindex={2}>
1469
+ <th scope="row" className={cn("sticky top-(--tf) left-0 z-40 border-r border-b border-line bg-surface px-1 text-xs font-normal text-ink-3", dense ? "h-7" : "h-9")}>
1470
+ <span style={{ "--s": `${SLOT}px` } as React.CSSProperties} className="grid w-(--s) place-items-center"><Search className="size-3.5" aria-hidden="true" /></span><span className="sr-only">Filters</span>
1471
+ </th>
1472
+ {visible.map((c, i) => {
1473
+ const here = pos.r === -1 && pos.c === i
1474
+ return (
1475
+ <td key={c.id} style={pinStyle(i)} className={cn("sticky top-(--tf) z-20 border-b border-line bg-surface px-1", dense ? "h-7" : "h-9", pinCls(i, true))}>
1476
+ <input
1477
+ data-cell={`-1:${i}`}
1478
+ data-filter={i}
1479
+ tabIndex={here ? 0 : -1}
1480
+ value={filters[c.id] ?? ""}
1481
+ placeholder="Search"
1482
+ aria-label={`Filter ${c.label}`}
1483
+ onFocus={() => { if (!here) { setPos({ r: -1, c: i }); setExt({ r: -1, c: i }) } }}
1484
+ onChange={(e) => {
1485
+ const q = e.target.value
1486
+ setFilters((f) => ({ ...f, [c.id]: q }))
1487
+ const n = rows.filter((r) => Object.entries({ ...filters, [c.id]: q }).every(([id, v]) => { const col = columns.find((x) => x.id === id); return !v.trim() || !col || copyText(col, r).toLowerCase().includes(v.trim().toLowerCase()) })).length
1488
+ say(`Showing ${plural(n, "row")} of ${rows.length.toLocaleString("en-GB")}.`)
1489
+ }}
1490
+ className={cn(
1491
+ "w-full min-w-0 rounded-xs border border-input bg-background px-control text-sm text-ink outline-none placeholder:text-ink-3 hover:border-input-hover focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/30",
1492
+ dense ? "h-5.5" : "h-7",
1493
+ c.type === "number" && "text-right",
1494
+ filters[c.id] && "border-primary"
1495
+ )}
1496
+ />
1497
+ </td>
1498
+ )
1499
+ })}
1500
+ </tr>
1501
+ )}
1502
+ {hasStatusRow && (
1503
+ <tr aria-rowindex={filterRow ? 3 : 2}>
1504
+ <th scope="row" title="Run status" className="sticky top-(--ts) left-0 z-40 h-7 border-r border-b border-line bg-surface px-1 text-xs font-normal text-ink-3">
1505
+ <span style={{ "--s": `${SLOT}px` } as React.CSSProperties} className="grid w-(--s) place-items-center"><Activity className="size-3.5" aria-hidden="true" /></span><span className="sr-only">Status</span>
1506
+ </th>
1507
+ {visible.map((c, i) => (
1508
+ <td key={c.id} style={pinStyle(i)} className={cn("sticky top-(--ts) z-20 h-7 border-b border-line bg-surface", dense ? "px-2" : "px-3", pinCls(i, true))}>
1509
+ {isComputed(c) && c.status ? <ColumnStatus status={c.status} /> : null}
1510
+ </td>
1511
+ ))}
1512
+ </tr>
1513
+ )}
1514
+ </thead>
1515
+ <tbody>
1516
+ {lines.map((line, r) => {
1517
+ if (line.kind === "group") {
1518
+ return (
1519
+ <tr key={`g:${line.key}`} aria-rowindex={r + headerRows + 1} data-slot="sheet-group-row">
1520
+ <th scope="row" className={cn("sticky left-0 z-10 border-r border-b border-line bg-surface-2", dense ? "h-7" : "h-9")}><span className="sr-only">Group</span></th>
1521
+ {visible.map((c, ci) => {
1522
+ const here = pos.r === r && pos.c === ci
1523
+ const agg = c.total ? aggregate(c, line.rows) : null
1524
+ return (
1525
+ <td
1526
+ key={c.id}
1527
+ role="gridcell"
1528
+ data-cell={`${r}:${ci}`}
1529
+ tabIndex={here ? 0 : -1}
1530
+ aria-readonly="true"
1531
+ aria-expanded={ci === 0 ? line.open : undefined}
1532
+ onMouseDown={() => go({ r, c: ci })}
1533
+ style={pinStyle(ci)}
1534
+ className={cn(
1535
+ "scroll-mt-(--hh) scroll-mb-(--fh) scroll-ml-(--pl) border-b border-line bg-surface-2 whitespace-nowrap select-none",
1536
+ cellCls,
1537
+ pinCls(ci),
1538
+ c.type === "number" && "tabular text-right text-sm text-ink-2",
1539
+ here ? "outline-2 -outline-offset-2 outline-solid outline-primary" : "outline-none"
1540
+ )}
1541
+ >
1542
+ {ci === 0 ? (
1543
+ <span className="flex min-w-0 items-center gap-control">
1544
+ <button type="button" tabIndex={-1} aria-label={`${line.open ? "Collapse" : "Expand"} ${line.label}`} onMouseDown={(e) => e.stopPropagation()} onClick={() => { go({ r, c: 0 }); toggleGroup(line.key) }} className="-ml-1 grid size-5 shrink-0 place-items-center rounded-xs text-ink-3 hover:bg-hover hover:text-ink">
1545
+ <ChevronRight aria-hidden="true" className={cn("size-3.5 transition-transform duration-(--duration-fast) ease-(--ease-out)", line.open && "rotate-90")} />
1546
+ </button>
1547
+ <span className="truncate font-medium text-ink">{line.label}</span>
1548
+ <span className="tabular shrink-0 text-sm text-ink-3">{line.rows.length}</span>
1549
+ </span>
1550
+ ) : agg !== null ? (
1551
+ <span>{c.total === "average" ? "Avg " : ""}{fmtFor(c, agg)}</span>
1552
+ ) : null}
1553
+ </td>
1554
+ )
1555
+ })}
1556
+ </tr>
1557
+ )
1558
+ }
1559
+ const row = line.row
1560
+ const multi = !!range && rangeCells > 1 && r >= range.r1 && r <= range.r2
1561
+ const active = pos.r === r
1562
+ return (
1563
+ <SheetBodyRow
1564
+ key={row.id}
1565
+ api={rowApi}
1566
+ row={row}
1567
+ n={line.n}
1568
+ r={r}
1569
+ rowIndex={r + headerRows + 1}
1570
+ visible={bodyCols}
1571
+ lefts={lefts}
1572
+ cellCls={cellCls}
1573
+ dense={dense}
1574
+ activeC={active ? pos.c : -1}
1575
+ editing={active ? editing : null}
1576
+ error={active ? error : ""}
1577
+ selC1={multi ? range.c1 : -1}
1578
+ selC2={multi ? range.c2 : -1}
1579
+ rejected={rejected}
1580
+ checked={selection.has(row.id)}
1581
+ selectable={selectable}
1582
+ selecting={selection.size > 0}
1583
+ slot={SLOT}
1584
+ hasRowMenu={hasRowMenu}
1585
+ menuOpen={rowMenu === row.id}
1586
+ openColId={onOpenRow ? nameCol?.id : undefined}
1587
+ />
1588
+ )
1589
+ })}
1590
+ </tbody>
1591
+ {hasTotals && (
1592
+ <tfoot>
1593
+ <tr aria-rowindex={lines.length + headerRows + 1}>
1594
+ <th scope="row" title="Total" className={cn("sticky bottom-0 left-0 z-40 border-t border-r border-line bg-surface-2 px-1 text-sm font-medium text-ink-2", dense ? "h-7" : "h-9")}>
1595
+ <span style={{ "--s": `${SLOT}px` } as React.CSSProperties} className="grid w-(--s) place-items-center"><Sigma className="size-3.5" aria-hidden="true" /></span><span className="sr-only">Total</span>
1596
+ </th>
1597
+ {visible.map((c, i) => {
1598
+ const agg = c.total ? aggregate(c, filteredRows) : null
1599
+ return (
1600
+ <td key={c.id} style={pinStyle(i)} className={cn("sticky bottom-0 z-20 border-t border-line bg-surface-2 text-sm font-medium whitespace-nowrap text-ink", cellCls, pinCls(i, true), c.type === "number" && "tabular text-right")}>
1601
+ {agg !== null ? `${c.total === "average" ? "Avg " : ""}${fmtFor(c, agg)}` : i === 0 ? plural(filteredRows.length, "row") : ""}
1602
+ </td>
1603
+ )
1604
+ })}
1605
+ </tr>
1606
+ </tfoot>
1607
+ )}
1608
+ </table>
1609
+ {lines.length === 0 && <p className="px-4 py-6 text-base text-ink-2">{rows.length ? "No rows match these filters." : "No rows yet. Add a row or import a CSV file."}</p>}
1610
+ </div>
1611
+ <div className="flex flex-wrap items-center gap-x-1 gap-y-1 border-t border-line px-2 py-1">
1612
+ {onAddRows && (
1613
+ <>
1614
+ {/* Adding rows is announced, not written into the status line: the new row itself is the confirmation. */}
1615
+ <Button variant="ghost" size="xs" onClick={() => { adding.current = lines.length; onAddRows(1); setAddSaid("Added 1 row at the bottom.") }}><Plus />Add row</Button>
1616
+ <Button variant="ghost" size="xs" onClick={() => { adding.current = lines.length; onAddRows(10); setAddSaid("Added 10 rows at the bottom.") }}>Add 10 rows</Button>
1617
+ <span aria-live="polite" className="sr-only">{addSaid}</span>
1618
+ <span className="mx-1 h-4 w-px bg-line" aria-hidden="true" />
1619
+ </>
1620
+ )}
1621
+ <p aria-live="polite" className={cn("min-w-0 flex-1 truncate text-sm", tone === "bad" || error ? "text-bad" : "text-ink-3")}>
1622
+ {status || `Arrow keys move, Shift+arrows select. Enter edits; Enter again saves and moves down. ${M}C copies, ${M}V pastes, ${M}Z undoes.`}
1623
+ </p>
1624
+ {rangeStats && <span className="tabular shrink-0 text-sm text-ink-2">{rangeStats}</span>}
1625
+ </div>
1626
+ </div>
1627
+ )
1628
+ }
1629
+
1630
+ /* ---------------------------------------------------------------- columns menu */
1631
+
1632
+ export type ColumnsMenuItem = { id: string; label: string; type?: ColumnType; provider?: ProviderId; hidden?: boolean; locked?: boolean }
1633
+
1634
+ /** Which columns show and in what order, for sheets and list tables. Applies at once; reorder by drag, by keyboard or with the Move buttons. */
1635
+ export function ColumnsMenu<T extends ColumnsMenuItem>({
1636
+ columns,
1637
+ onChange,
1638
+ align = "end",
1639
+ wrap,
1640
+ onWrapChange,
1641
+ pageSize,
1642
+ pageSizes = [25, 50, 100],
1643
+ onPageSizeChange,
1644
+ note,
1645
+ }: {
1646
+ columns: T[]
1647
+ onChange: (columns: T[]) => void
1648
+ align?: "start" | "end"
1649
+ /** List tables: wrap long text in cells. Shown when onWrapChange is passed. */
1650
+ wrap?: boolean
1651
+ onWrapChange?: (wrap: boolean) => void
1652
+ /** List tables: rows per page. Shown when onPageSizeChange is passed. */
1653
+ pageSize?: number
1654
+ pageSizes?: number[]
1655
+ onPageSizeChange?: (size: number) => void
1656
+ /** Where the choice is kept, such as "Saved for you on this table". */
1657
+ note?: string
1658
+ }) {
1659
+ const [query, setQuery] = React.useState("")
1660
+ const [grabbed, setGrabbed] = React.useState<{ id: string; before: T[] } | null>(null)
1661
+ const [dragId, setDragId] = React.useState<string | null>(null)
1662
+ const [said, setSaid] = React.useState("")
1663
+ const shownCount = columns.filter((c) => !c.hidden).length
1664
+ const list = columns.filter((c) => c.label.toLowerCase().includes(query.trim().toLowerCase()))
1665
+
1666
+ const moveTo = (id: string, to: number) => {
1667
+ const from = columns.findIndex((c) => c.id === id)
1668
+ if (from < 0 || to < 0 || to >= columns.length || to === from) return
1669
+ const next = [...columns]
1670
+ const [item] = next.splice(from, 1)
1671
+ next.splice(to, 0, item)
1672
+ onChange(next)
1673
+ setSaid(`${item.label} moved to position ${to + 1} of ${columns.length}.`)
1674
+ }
1675
+ const setHidden = (id: string, hidden: boolean) => onChange(columns.map((c) => (c.id === id ? { ...c, hidden } : c)))
1676
+
1677
+ return (
1678
+ <Popover onOpenChange={(o) => { if (!o) { setQuery(""); setGrabbed(null) } }}>
1679
+ <PopoverTrigger asChild>
1680
+ <Button variant="outline" size="sm"><Columns3 />Columns</Button>
1681
+ </PopoverTrigger>
1682
+ <PopoverContent align={align} className="w-80 p-0">
1683
+ <div className="grid gap-2 border-b border-line p-2">
1684
+ <div className="relative">
1685
+ <Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-ink-3" aria-hidden="true" />
1686
+ <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Find a column" aria-label="Find a column" className="pl-8" />
1687
+ </div>
1688
+ <div className="flex items-center gap-1">
1689
+ <span className="tabular flex-1 text-sm text-ink-2">{shownCount} of {columns.length} shown</span>
1690
+ <Button variant="ghost" size="xs" onClick={() => onChange(columns.map((c) => ({ ...c, hidden: false })))}>Show all</Button>
1691
+ <Button variant="ghost" size="xs" onClick={() => onChange(columns.map((c) => (c.locked ? c : { ...c, hidden: true })))}>Hide all</Button>
1692
+ </div>
1693
+ </div>
1694
+ <ul aria-label="Columns, in table order" className="grid max-h-80 overflow-y-auto p-1">
1695
+ {list.map((c) => {
1696
+ const i = columns.indexOf(c)
1697
+ const isGrabbed = grabbed?.id === c.id
1698
+ return (
1699
+ <li
1700
+ key={c.id}
1701
+ onDragOver={(e) => { if (dragId) e.preventDefault() }}
1702
+ onDrop={(e) => { e.preventDefault(); if (dragId) moveTo(dragId, i); setDragId(null) }}
1703
+ className={cn("flex h-8 items-center gap-control rounded-xs px-1 hover:bg-hover", (isGrabbed || dragId === c.id) && "bg-selected")}
1704
+ >
1705
+ <button
1706
+ type="button"
1707
+ draggable={!query}
1708
+ onDragStart={(e) => { e.dataTransfer.effectAllowed = "move"; setDragId(c.id) }}
1709
+ onDragEnd={() => setDragId(null)}
1710
+ aria-label={`Reorder ${c.label}`}
1711
+ aria-pressed={isGrabbed}
1712
+ aria-describedby="columns-menu-help"
1713
+ onKeyDown={(e) => {
1714
+ if (e.key === " " || e.key === "Enter") {
1715
+ e.preventDefault()
1716
+ if (isGrabbed) { setGrabbed(null); setSaid(`Dropped ${c.label} at position ${i + 1} of ${columns.length}.`) }
1717
+ else { setGrabbed({ id: c.id, before: columns }); setSaid(`Picked up ${c.label}. Use the arrow keys to move it, Space to drop, Esc to cancel.`) }
1718
+ } else if (isGrabbed && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
1719
+ e.preventDefault()
1720
+ moveTo(c.id, i + (e.key === "ArrowUp" ? -1 : 1))
1721
+ } else if (isGrabbed && e.key === "Escape") {
1722
+ e.preventDefault(); e.stopPropagation()
1723
+ onChange(grabbed.before); setGrabbed(null); setSaid(`Move cancelled. ${c.label} is back where it was.`)
1724
+ }
1725
+ }}
1726
+ className="relative grid size-6 shrink-0 cursor-grab place-items-center rounded-xs text-ink-3 hover:text-ink"
1727
+ >
1728
+ <GripVertical className="size-3.5" aria-hidden="true" />
1729
+ </button>
1730
+ {c.type && <ColumnTypeIcon type={c.type} provider={c.provider} />}
1731
+ <span className={cn("min-w-0 flex-1 truncate text-base", c.hidden ? "text-ink-3" : "text-ink")}>{c.label}</span>
1732
+ <Button variant="ghost" size="icon-xs" aria-label={`Move ${c.label} up`} disabled={i === 0} onClick={() => moveTo(c.id, i - 1)}><ArrowUp /></Button>
1733
+ <Button variant="ghost" size="icon-xs" aria-label={`Move ${c.label} down`} disabled={i === columns.length - 1} onClick={() => moveTo(c.id, i + 1)}><ArrowDown /></Button>
1734
+ {c.locked ? (
1735
+ <span className="grid size-6 place-items-center text-ink-3"><Lock className="size-3.5" aria-hidden="true" /><span className="sr-only">Always shown</span></span>
1736
+ ) : (
1737
+ <Button variant="ghost" size="icon-xs" aria-label={c.hidden ? `Show ${c.label}` : `Hide ${c.label}`} aria-pressed={!c.hidden} onClick={() => setHidden(c.id, !c.hidden)}>
1738
+ {c.hidden ? <EyeOff /> : <Eye className="text-ink" />}
1739
+ </Button>
1740
+ )}
1741
+ </li>
1742
+ )
1743
+ })}
1744
+ {!list.length && <li className="px-2 py-3 text-base text-ink-2">No columns match “{query}”.</li>}
1745
+ </ul>
1746
+ {(onWrapChange || onPageSizeChange) && (
1747
+ <div className="grid gap-2 border-t border-line px-3 py-2">
1748
+ {onWrapChange && (
1749
+ <label className="flex h-7 items-center justify-between gap-3 text-base text-ink">
1750
+ Wrap long text
1751
+ <Switch checked={!!wrap} onCheckedChange={onWrapChange} />
1752
+ </label>
1753
+ )}
1754
+ {onPageSizeChange && (
1755
+ <div className="flex h-7 items-center justify-between gap-3 text-base text-ink">
1756
+ <span id="columns-menu-page-size">Rows per page</span>
1757
+ <RadioGroup aria-labelledby="columns-menu-page-size" value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))} className="flex gap-3">
1758
+ {pageSizes.map((n) => (
1759
+ <label key={n} className="tabular flex items-center gap-control text-base text-ink">
1760
+ <RadioGroupItem value={String(n)} />
1761
+ {n}
1762
+ </label>
1763
+ ))}
1764
+ </RadioGroup>
1765
+ </div>
1766
+ )}
1767
+ </div>
1768
+ )}
1769
+ <p id="columns-menu-help" className="border-t border-line px-3 py-2 text-xs text-ink-3">{note ? `${note}. ` : ""}Drag a handle, or focus it and press Space, then the arrow keys.</p>
1770
+ <p aria-live="assertive" className="sr-only">{said}</p>
1771
+ </PopoverContent>
1772
+ </Popover>
1773
+ )
1774
+ }
1775
+
1776
+ /* ---------------------------------------------------------------- run status bar */
1777
+
1778
+ export type RunHistoryItem = { id: string; label: string; rows: number; credits: number; outcome: "Done" | "Stopped" | "Failed"; errors?: number; when: React.ReactNode }
1779
+
1780
+ /** The bottom bar of a sheet: what is running, how far it is, what it has cost, and Stop. */
1781
+ export function RunStatusBar({
1782
+ running,
1783
+ done,
1784
+ total,
1785
+ errors = 0,
1786
+ creditsUsed,
1787
+ costPerRow,
1788
+ label,
1789
+ onStop,
1790
+ onRunRemaining,
1791
+ onShowErrors,
1792
+ autoRun,
1793
+ onAutoRunChange,
1794
+ history,
1795
+ className,
1796
+ }: {
1797
+ running: boolean
1798
+ /** Rows finished (done, failed or not found) in the current or last run. */
1799
+ done: number
1800
+ total: number
1801
+ errors?: number
1802
+ /** Credits spent by the current or last run. */
1803
+ creditsUsed: number
1804
+ /** Credits per row for every computed column together; stated next to Auto-run and Run remaining. */
1805
+ costPerRow: number
1806
+ /** What is running: "Summarise role and Work email". */
1807
+ label?: string
1808
+ onStop: () => void
1809
+ onRunRemaining?: () => void
1810
+ onShowErrors?: () => void
1811
+ autoRun: boolean
1812
+ onAutoRunChange: (on: boolean) => void
1813
+ history?: RunHistoryItem[]
1814
+ className?: string
1815
+ }) {
1816
+ const pct = total ? Math.floor((done / total) * 100) : 100
1817
+ const n = (x: number) => x.toLocaleString("en-GB")
1818
+ const remaining = total - done
1819
+ const errorText = errors ? `${n(errors)} ${errors === 1 ? "error" : "errors"}` : ""
1820
+ const historyTitle = React.useId()
1821
+ return (
1822
+ <div role="region" aria-label="Run status" data-slot="run-status-bar" className={cn("flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-line bg-surface px-3 py-2 text-sm", className)}>
1823
+ <div role="status" className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
1824
+ {running ? (
1825
+ <>
1826
+ <Loader2 className="size-3.5 shrink-0 animate-spin text-ink-3" aria-hidden="true" />
1827
+ <ShimmerText className="font-medium">{label ? `Running ${label}` : "Running"}</ShimmerText>
1828
+ <span className="tabular text-ink-2">{n(done)} of {n(total)} rows · {pct}%</span>
1829
+ <span role="progressbar" aria-label="Rows run" aria-valuemin={0} aria-valuemax={total} aria-valuenow={done} className="h-1.5 w-24 overflow-hidden rounded-full bg-hover">
1830
+ <span className="block h-full w-(--p) rounded-full bg-primary transition-all duration-(--duration-slow) ease-(--ease-standard)" style={{ "--p": `${pct}%` } as React.CSSProperties} />
1831
+ </span>
1832
+ <span className="tabular text-ink-2">{formatCredits(creditsUsed)} so far</span>
1833
+ </>
1834
+ ) : remaining > 0 ? (
1835
+ <>
1836
+ <Square className="size-3 shrink-0 text-ink-3" aria-hidden="true" />
1837
+ <span className="font-medium text-ink">Stopped at {pct}%</span>
1838
+ <span className="tabular text-ink-2">{n(done)} of {n(total)} rows · {formatCredits(creditsUsed)} used</span>
1839
+ </>
1840
+ ) : (
1841
+ <>
1842
+ <Check className="size-3.5 shrink-0 text-ok" aria-hidden="true" />
1843
+ <span className="font-medium text-ink">{errors ? "Every row has run" : "Table up to date"}</span>
1844
+ <span className="tabular text-ink-2">{n(total)} rows · {formatCredits(creditsUsed)} used</span>
1845
+ </>
1846
+ )}
1847
+ {errorText && (onShowErrors
1848
+ ? <Button variant="link" size="xs" className="text-bad" onClick={onShowErrors}><CircleAlert />{errorText}</Button>
1849
+ : <span className="inline-flex items-center gap-1 text-bad"><CircleAlert className="size-3.5" aria-hidden="true" />{errorText}</span>)}
1850
+ </div>
1851
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
1852
+ {running ? (
1853
+ <Button variant="outline" size="sm" onClick={onStop}><Square />Stop</Button>
1854
+ ) : remaining > 0 && onRunRemaining ? (
1855
+ <Button variant="outline" size="sm" onClick={onRunRemaining}>Run {n(remaining)} remaining · ≈ {formatCredits(remaining * costPerRow)}</Button>
1856
+ ) : null}
1857
+ <label className="flex items-center gap-2 text-ink">
1858
+ <Switch checked={autoRun} onCheckedChange={onAutoRunChange} aria-describedby="auto-run-cost" />
1859
+ Auto-run new rows
1860
+ <span id="auto-run-cost" className="tabular text-ink-3">≈ {formatCredits(costPerRow)} / row</span>
1861
+ </label>
1862
+ {history && (
1863
+ <Popover>
1864
+ <PopoverTrigger asChild><Button variant="ghost" size="sm"><History />History</Button></PopoverTrigger>
1865
+ {/* Focus the panel itself on open, not the first row: a relative Timestamp there would open its tooltip unasked. Tab reaches the rows. */}
1866
+ <PopoverContent align="end" tabIndex={-1} aria-labelledby={historyTitle} onOpenAutoFocus={(e) => { e.preventDefault(); (e.target as HTMLElement).focus() }} className="w-96 max-w-(--radix-popover-content-available-width) p-0">
1867
+ <h3 id={historyTitle} className="border-b border-line px-3 py-2 text-base font-semibold text-ink">Run history</h3>
1868
+ {history.length ? (
1869
+ <ul className="grid max-h-80 divide-y divide-line overflow-y-auto">
1870
+ {history.map((h) => (
1871
+ <li key={h.id} className="grid gap-0.5 px-3 py-2">
1872
+ <span className="flex items-center gap-2"><span className="min-w-0 flex-1 truncate text-base text-ink">{h.label}</span><Badge variant={h.outcome === "Done" ? "success" : h.outcome === "Failed" ? "destructive" : "secondary"} dot>{h.outcome}</Badge></span>
1873
+ <span className="tabular text-sm text-ink-2">{n(h.rows)} rows · {formatCredits(h.credits)}{h.errors ? ` · ${n(h.errors)} ${h.errors === 1 ? "error" : "errors"}` : ""} · {h.when}</span>
1874
+ </li>
1875
+ ))}
1876
+ </ul>
1877
+ ) : <p className="px-3 py-3 text-base text-ink-2">No runs yet.</p>}
1878
+ </PopoverContent>
1879
+ </Popover>
1880
+ )}
1881
+ </div>
1882
+ </div>
1883
+ )
1884
+ }