@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,465 @@
1
+ /**
2
+ * TwoUX compositions for bringing data in.
3
+ * FieldMapping: each target field with the source column that fills it, required fields marked, type mismatches
4
+ * warned in words, and the other source columns either skipped or created as new columns. Reusable for imports
5
+ * and automations alike.
6
+ * CsvImport: choose a CSV file (or a sample), set the delimiter and header row, map the columns, see the first 20 rows
7
+ * with problem cells marked in words, read the summary of what will be skipped, then "Import 1,248 rows".
8
+ */
9
+ import { ArrowLeft, CircleAlert, FileSpreadsheet, TriangleAlert } from "lucide-react"
10
+ import * as React from "react"
11
+ import { cn } from "../lib/cn"
12
+ import { Alert, AlertDescription, AlertTitle } from "./alert"
13
+ import { Button } from "./button"
14
+ import { Checkbox } from "./checkbox"
15
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./dialog"
16
+ import { Input } from "./input"
17
+ import { FileDrop } from "./onboarding"
18
+ import { RadioGroup, RadioGroupItem } from "./radio-group"
19
+ import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./select"
20
+ import { Switch } from "./switch"
21
+ import { ColumnTypeIcon, toIsoDate, type ColumnType } from "./workspace"
22
+
23
+ /* ---------------------------------------------------------------- values */
24
+
25
+ export type FieldKind = "text" | "number" | "date" | "email" | "url" | "select"
26
+ export type MappingTarget = { id: string; label: string; kind: FieldKind; required?: boolean; options?: string[] }
27
+ export type MappingSource = { id: string; label: string; samples: string[] }
28
+ /** Target field id → source column id, or null for not filled. */
29
+ export type FieldMap = Record<string, string | null>
30
+ /** Source column id → what happens to a column no field uses. */
31
+ export type ExtraColumns = Record<string, "skip" | "create">
32
+
33
+ const KIND_WORD: Record<FieldKind, string> = { text: "text", number: "a number", date: "a date", email: "an email address", url: "a link", select: "one of the choices" }
34
+ const KIND_ICON: Record<FieldKind, ColumnType> = { text: "text", number: "number", date: "date", email: "text", url: "text", select: "select" }
35
+
36
+ /** The problem with a value for this kind of field, in two or three words, or undefined when it fits. */
37
+ export function checkFieldValue(kind: FieldKind, value: string, options?: string[]): string | undefined {
38
+ const v = value.trim()
39
+ if (!v) return undefined
40
+ if (kind === "number" && Number.isNaN(Number(v.replace(/[,£$€\s]/g, "")))) return "Not a number"
41
+ if (kind === "date" && !toIsoDate(v)) return "Not a date"
42
+ if (kind === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) return "Invalid email"
43
+ if (kind === "url" && !/^(https?:\/\/)?[\w-]+(\.[\w-]+)+\S*$/.test(v)) return "Not a link"
44
+ if (kind === "select" && options && !options.some((o) => o.toLowerCase() === v.toLowerCase())) return "Not a choice"
45
+ return undefined
46
+ }
47
+
48
+ const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
49
+ /** Pairs fields and columns whose names match, ignoring case and punctuation ("E-mail" fills "Email"). */
50
+ export function autoMapFields(targets: MappingTarget[], sources: MappingSource[], aliases: Record<string, string[]> = {}): FieldMap {
51
+ const used = new Set<string>()
52
+ return Object.fromEntries(targets.map((t) => {
53
+ const names = [t.label, t.id, ...(aliases[t.id] ?? [])].map(norm)
54
+ const s = sources.find((x) => !used.has(x.id) && names.includes(norm(x.label))) ?? sources.find((x) => !used.has(x.id) && names.some((n) => norm(x.label).includes(n) || n.includes(norm(x.label))))
55
+ if (s) used.add(s.id)
56
+ return [t.id, s?.id ?? null]
57
+ }))
58
+ }
59
+
60
+ /** RFC 4180 CSV: quoted fields, doubled quotes, line breaks inside quotes. */
61
+ export function parseCsv(text: string, delimiter = ","): string[][] {
62
+ const rows: string[][] = []
63
+ let row: string[] = [], cell = "", quoted = false
64
+ for (let i = 0; i < text.length; i++) {
65
+ const ch = text[i]
66
+ if (quoted) {
67
+ if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++ }
68
+ else if (ch === '"') quoted = false
69
+ else cell += ch
70
+ } else if (ch === '"' && cell === "") quoted = true
71
+ else if (ch === delimiter) { row.push(cell); cell = "" }
72
+ else if (ch === "\n" || ch === "\r") {
73
+ if (ch === "\r" && text[i + 1] === "\n") i++
74
+ row.push(cell); cell = ""
75
+ if (row.some((c) => c !== "")) rows.push(row)
76
+ row = []
77
+ } else cell += ch
78
+ }
79
+ row.push(cell)
80
+ if (row.some((c) => c !== "")) rows.push(row)
81
+ return rows
82
+ }
83
+
84
+ export const detectDelimiter = (text: string) => {
85
+ const line = text.split(/\r?\n/)[0] ?? ""
86
+ return [",", ";", "\t", "|"].map((d) => [d, line.split(d).length] as const).sort((a, b) => b[1] - a[1])[0][0]
87
+ }
88
+
89
+ /* ---------------------------------------------------------------- field mapping */
90
+
91
+ export function FieldMapping({
92
+ targets,
93
+ sources,
94
+ value,
95
+ onChange,
96
+ extra,
97
+ onExtraChange,
98
+ errors = {},
99
+ targetHeading = "Field in the table",
100
+ sourceHeading = "Column in the file",
101
+ className,
102
+ }: {
103
+ targets: MappingTarget[]
104
+ sources: MappingSource[]
105
+ value: FieldMap
106
+ onChange: (value: FieldMap) => void
107
+ /** What happens to source columns no field uses. Omit both to hide that section. */
108
+ extra?: ExtraColumns
109
+ onExtraChange?: (extra: ExtraColumns) => void
110
+ /** Messages per target id after a failed submit, e.g. a required field with no column. */
111
+ errors?: Record<string, string>
112
+ targetHeading?: string
113
+ sourceHeading?: string
114
+ className?: string
115
+ }) {
116
+ const id = React.useId()
117
+ const used = new Set(Object.values(value).filter(Boolean) as string[])
118
+ const unused = sources.filter((s) => !used.has(s.id))
119
+ const NONE = "__none"
120
+ return (
121
+ <div data-slot="field-mapping" className={cn("grid gap-4", className)}>
122
+ <div role="table" aria-label="Field mapping" className="grid">
123
+ <div role="row" className="grid grid-cols-[minmax(0,1fr)_1.25rem_minmax(0,1fr)] items-center gap-2 border-b border-line pb-2 text-sm font-medium text-ink-2">
124
+ <span role="columnheader">{targetHeading}</span><span aria-hidden="true" /><span role="columnheader">{sourceHeading}</span>
125
+ </div>
126
+ {targets.map((t) => {
127
+ const src = sources.find((s) => s.id === value[t.id])
128
+ const bad = src ? src.samples.filter((v) => checkFieldValue(t.kind, v, t.options)).length : 0
129
+ const err = errors[t.id]
130
+ const fid = `${id}-${t.id}`
131
+ return (
132
+ <div key={t.id} role="row" className="grid gap-1 border-b border-line py-2 last:border-b-0">
133
+ <div className="grid grid-cols-[minmax(0,1fr)_1.25rem_minmax(0,1fr)] items-center gap-2">
134
+ <label role="rowheader" htmlFor={fid} className="flex min-w-0 items-center gap-control text-base text-ink">
135
+ <ColumnTypeIcon type={KIND_ICON[t.kind]} />
136
+ <span className="truncate">{t.label}</span>
137
+ {t.required && <span className="shrink-0 text-sm text-ink-3">Required</span>}
138
+ </label>
139
+ <ArrowLeft className="size-3.5 text-ink-3" aria-hidden="true" />
140
+ <span role="cell" className="min-w-0">
141
+ <Select value={value[t.id] ?? NONE} onValueChange={(v) => onChange({ ...value, [t.id]: v === NONE ? null : v })}>
142
+ <SelectTrigger id={fid} size="sm" className="w-full" aria-invalid={!!err || undefined} aria-describedby={err || bad ? `${fid}-msg` : undefined}><SelectValue /></SelectTrigger>
143
+ <SelectContent>
144
+ <SelectItem value={NONE}>{t.required ? "Choose a column" : "Leave empty"}</SelectItem>
145
+ <SelectSeparator />
146
+ {sources.map((s) => (
147
+ <SelectItem key={s.id} value={s.id} disabled={used.has(s.id) && value[t.id] !== s.id}>
148
+ {s.label}{s.samples[0] ? <span className="text-ink-3"> · {s.samples[0]}</span> : null}
149
+ </SelectItem>
150
+ ))}
151
+ </SelectContent>
152
+ </Select>
153
+ </span>
154
+ </div>
155
+ {err ? (
156
+ <p id={`${fid}-msg`} className="flex items-start gap-control text-sm text-bad"><CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />{err}</p>
157
+ ) : bad > 0 && src ? (
158
+ <p id={`${fid}-msg`} className="flex items-start gap-control text-sm text-warn"><TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />{bad} of {src.samples.length} values in “{src.label}” aren't {KIND_WORD[t.kind]}.</p>
159
+ ) : null}
160
+ </div>
161
+ )
162
+ })}
163
+ </div>
164
+ {extra && onExtraChange && unused.length > 0 && (
165
+ <section aria-labelledby={`${id}-other`} className="grid gap-1">
166
+ <h3 id={`${id}-other`} className="text-sm font-medium text-ink-2">Other columns in the file</h3>
167
+ <ul className="grid divide-y divide-line rounded-sm border border-line">
168
+ {unused.map((s) => (
169
+ <li key={s.id} className="flex flex-wrap items-center gap-2 px-3 py-2">
170
+ <span className="grid min-w-0 flex-1"><span className="truncate text-base text-ink">{s.label}</span>{s.samples[0] && <span className="truncate text-sm text-ink-3">e.g. {s.samples[0]}</span>}</span>
171
+ <Select value={extra[s.id] ?? "skip"} onValueChange={(v) => onExtraChange({ ...extra, [s.id]: v as "skip" | "create" })}>
172
+ <SelectTrigger size="sm" className="w-40" aria-label={`What to do with ${s.label}`}><SelectValue /></SelectTrigger>
173
+ <SelectContent>
174
+ <SelectItem value="skip">Don't import</SelectItem>
175
+ <SelectItem value="create">Create column</SelectItem>
176
+ </SelectContent>
177
+ </Select>
178
+ </li>
179
+ ))}
180
+ </ul>
181
+ </section>
182
+ )}
183
+ </div>
184
+ )
185
+ }
186
+
187
+ /* ---------------------------------------------------------------- CSV import */
188
+
189
+ export type CsvImportResult = {
190
+ /** One object per imported row, keyed by target id; created columns are keyed by their source label. Date fields arrive as ISO 8601 (2026-11-02); 02/11/2026 is read day first, as in the UK. */
191
+ rows: Record<string, string>[]
192
+ /** Source labels turned into new columns. */
193
+ created: string[]
194
+ skipped: number
195
+ /** Why rows were skipped or imported with empty cells, one entry per field and problem, so the app can say what happened. */
196
+ problems: CsvImportProblem[]
197
+ }
198
+
199
+ export type CsvImportProblem = {
200
+ /** Target id and label of the field. */
201
+ field: string
202
+ label: string
203
+ /** missing: a required value was empty. invalid: the value didn't fit the field (Not a date, Invalid email…). */
204
+ problem: "missing" | "invalid"
205
+ /** Rows with this problem. A row with two problems counts once in each. */
206
+ rows: number
207
+ /** What happened to those rows: skipped, or imported with this cell left empty. */
208
+ outcome: "skipped" | "emptied"
209
+ }
210
+
211
+ const DELIMS = [["auto", "Detect automatically"], [",", "Comma"], [";", "Semicolon"], ["\t", "Tab"], ["|", "Pipe"]] as const
212
+ const n = (x: number) => x.toLocaleString("en-GB")
213
+
214
+ export function CsvImport({
215
+ open,
216
+ onOpenChange,
217
+ targets,
218
+ tableName,
219
+ sample,
220
+ aliases,
221
+ onImport,
222
+ }: {
223
+ open: boolean
224
+ onOpenChange: (open: boolean) => void
225
+ targets: MappingTarget[]
226
+ /** "People" */
227
+ tableName: string
228
+ /** Lets people try the flow without a file. */
229
+ sample?: { name: string; text: string }
230
+ /** Other names a field goes by, for automatic mapping: { email: ["work email"] } */
231
+ aliases?: Record<string, string[]>
232
+ onImport: (result: CsvImportResult) => void
233
+ }) {
234
+ const [file, setFile] = React.useState<{ name: string; text: string } | null>(null)
235
+ const [fileError, setFileError] = React.useState("")
236
+ const [delim, setDelim] = React.useState<string>("auto")
237
+ const [header, setHeader] = React.useState(true)
238
+ const [step, setStep] = React.useState<1 | 2>(1)
239
+ const [map, setMap] = React.useState<FieldMap>({})
240
+ const [extra, setExtra] = React.useState<ExtraColumns>({})
241
+ const [badRows, setBadRows] = React.useState<"skip" | "empty">("skip")
242
+ const [onlyProblems, setOnlyProblems] = React.useState(false)
243
+ const [edits, setEdits] = React.useState<Record<string, string>>({})
244
+ const [fixing, setFixing] = React.useState<string | null>(null)
245
+ const [errors, setErrors] = React.useState<Record<string, string>>({})
246
+ const summary = React.useRef<HTMLDivElement>(null)
247
+
248
+ React.useEffect(() => {
249
+ if (!open) return
250
+ setFile(null); setFileError(""); setDelim("auto"); setHeader(true); setStep(1); setMap({}); setExtra({}); setBadRows("skip"); setOnlyProblems(false); setEdits({}); setFixing(null); setErrors({})
251
+ }, [open])
252
+
253
+ const parsed = React.useMemo(() => {
254
+ if (!file) return null
255
+ const d = delim === "auto" ? detectDelimiter(file.text) : delim
256
+ const all = parseCsv(file.text, d)
257
+ const width = Math.max(0, ...all.map((r) => r.length))
258
+ const head = header ? all[0] ?? [] : []
259
+ const body = header ? all.slice(1) : all
260
+ const sources: MappingSource[] = Array.from({ length: width }, (_, i) => ({ id: `c${i}`, label: (head[i] ?? "").trim() || `Column ${i + 1}`, samples: body.slice(0, 20).map((r) => r[i] ?? "").filter(Boolean) }))
261
+ return { sources, body, delimiter: d }
262
+ }, [file, delim, header])
263
+
264
+ // Map once per parsed file; targets and aliases may be new objects on every render.
265
+ const mapInputs = React.useRef({ targets, aliases })
266
+ mapInputs.current = { targets, aliases }
267
+ React.useEffect(() => { if (parsed) { setMap(autoMapFields(mapInputs.current.targets, parsed.sources, mapInputs.current.aliases)); setExtra({}); setEdits({}) } }, [parsed])
268
+
269
+ const cellOf = (r: number, sid: string) => edits[`${r}:${sid}`] ?? parsed?.body[r]?.[Number(sid.slice(1))] ?? ""
270
+ const problems = React.useMemo(() => {
271
+ if (!parsed) return { byRow: new Map<number, Map<string, string>>(), byTarget: new Map<string, number>(), missing: 0 }
272
+ const byRow = new Map<number, Map<string, string>>()
273
+ const byTarget = new Map<string, number>()
274
+ let missing = 0
275
+ parsed.body.forEach((_, r) => {
276
+ let rowMissing = false
277
+ for (const t of targets) {
278
+ const sid = map[t.id]
279
+ if (!sid) continue
280
+ const v = cellOf(r, sid)
281
+ const p = t.required && !v.trim() ? `No ${t.label.toLowerCase()}` : checkFieldValue(t.kind, v, t.options)
282
+ if (!p) continue
283
+ if (t.required && !v.trim()) rowMissing = true
284
+ if (!byRow.has(r)) byRow.set(r, new Map())
285
+ byRow.get(r)!.set(sid, p)
286
+ const key = `${t.id}:${t.required && !v.trim() ? "missing" : "invalid"}`
287
+ byTarget.set(key, (byTarget.get(key) ?? 0) + 1)
288
+ }
289
+ if (rowMissing) missing++
290
+ })
291
+ return { byRow, byTarget, missing }
292
+ // eslint-disable-next-line react-hooks/exhaustive-deps
293
+ }, [parsed, map, edits, targets])
294
+
295
+ const total = parsed?.body.length ?? 0
296
+ const problemRows = problems.byRow.size
297
+ const skipped = badRows === "skip" ? problemRows : problems.missing
298
+ const importing = total - skipped
299
+
300
+ const take = async (f: File) => {
301
+ if (!/\.csv$/i.test(f.name)) { setFileError("That isn't a CSV file. Save the sheet as CSV and choose it again."); return }
302
+ setFileError("")
303
+ setFile({ name: f.name, text: await f.text() })
304
+ }
305
+
306
+ const next = () => {
307
+ if (!parsed || !parsed.body.length) { setFileError(parsed ? "That file has no rows. Choose a file with at least one row under the column names." : "Choose a CSV file, or use the sample file."); return }
308
+ setStep(2)
309
+ }
310
+ const submit = () => {
311
+ const errs: Record<string, string> = {}
312
+ for (const t of targets) if (t.required && !map[t.id]) errs[t.id] = `Choose the column that holds ${t.label.toLowerCase()}. It's required for every row.`
313
+ setErrors(errs)
314
+ if (Object.keys(errs).length) { summary.current?.focus(); return }
315
+ const created = Object.entries(extra).filter(([, v]) => v === "create").map(([sid]) => sid)
316
+ const rows: Record<string, string>[] = []
317
+ parsed!.body.forEach((_, r) => {
318
+ const p = problems.byRow.get(r)
319
+ const missingRequired = targets.some((t) => t.required && map[t.id] && !cellOf(r, map[t.id]!).trim())
320
+ if (p && (badRows === "skip" || missingRequired)) return
321
+ const out: Record<string, string> = {}
322
+ for (const t of targets) { const sid = map[t.id]; if (!sid) continue; const v = p?.has(sid) ? "" : cellOf(r, sid); out[t.id] = t.kind === "date" && v.trim() ? (toIsoDate(v.trim()) ?? "") : v }
323
+ for (const sid of created) out[parsed!.sources.find((s) => s.id === sid)!.label] = cellOf(r, sid)
324
+ rows.push(out)
325
+ })
326
+ const report: CsvImportProblem[] = [...problems.byTarget.entries()].map(([key, count]) => {
327
+ const [tid, why] = key.split(":") as [string, "missing" | "invalid"]
328
+ const t = targets.find((x) => x.id === tid)!
329
+ return { field: tid, label: t.label, problem: why, rows: count, outcome: why === "missing" || badRows === "skip" ? "skipped" : "emptied" }
330
+ })
331
+ onImport({ rows, created: created.map((sid) => parsed!.sources.find((s) => s.id === sid)!.label), skipped: total - rows.length, problems: report })
332
+ onOpenChange(false)
333
+ }
334
+
335
+ const mappedCols = parsed ? [
336
+ ...targets.filter((t) => map[t.id]).map((t) => ({ key: t.id, label: t.label, sid: map[t.id]! })),
337
+ ...Object.entries(extra).filter(([, v]) => v === "create").map(([sid]) => ({ key: sid, label: `${parsed.sources.find((s) => s.id === sid)?.label} (new)`, sid })),
338
+ ] : []
339
+ const previewRows = parsed ? (onlyProblems ? [...problems.byRow.keys()] : parsed.body.map((_, i) => i)).slice(0, 20) : []
340
+ const PHRASE: Record<FieldKind, string> = { email: "an invalid email", number: "a value that isn't a number", date: "a date that can't be read", url: "an invalid link", select: "a value that isn't one of the choices", text: "an invalid value" }
341
+ const summaryLines = [...problems.byTarget.entries()].map(([key, count]) => {
342
+ const [tid, why] = key.split(":")
343
+ const t = targets.find((x) => x.id === tid)!
344
+ return `${n(count)} ${count === 1 ? "row has" : "rows have"} ${why === "missing" ? `no ${t.label.toLowerCase()}` : PHRASE[t.kind]}.`
345
+ })
346
+
347
+ return (
348
+ <Dialog open={open} onOpenChange={onOpenChange}>
349
+ <DialogContent className="max-h-9/10 grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-4xl">
350
+ <DialogHeader>
351
+ <p className="text-sm text-ink-3">Step {step} of 2</p>
352
+ <DialogTitle>{step === 1 ? `Import a CSV file into ${tableName}` : "Match columns and check the rows"}</DialogTitle>
353
+ <DialogDescription>{step === 1 ? "Rows are added at the bottom of the table. Nothing changes until you press Import." : `${file?.name} · ${n(total)} rows · ${parsed?.sources.length} columns`}</DialogDescription>
354
+ </DialogHeader>
355
+
356
+ <div className="-mx-6 min-h-0 overflow-y-auto px-6">
357
+ {step === 1 ? (
358
+ <div className="grid gap-4 pb-1">
359
+ <FileDrop id="csv-file" label="CSV file" accept="text/csv,application/vnd.ms-excel,.csv" hint="CSV, with a header row, up to 10 MB" maxBytes={10_000_000} onFile={take} error={fileError} />
360
+ {sample && !file && <p className="text-sm text-ink-2">No file to hand? <Button variant="link" size="xs" onClick={() => { setFile(sample); setFileError("") }}>Use the sample file</Button></p>}
361
+ {file && parsed && (
362
+ <p className="flex items-center gap-2 text-base text-ink"><FileSpreadsheet className="size-4 text-ink-3" aria-hidden="true" />{file.name}<span className="tabular text-ink-2">· {n(parsed.body.length)} rows · {parsed.sources.length} columns</span></p>
363
+ )}
364
+ <div className="grid gap-3 sm:grid-cols-2">
365
+ <div className="grid gap-2">
366
+ <label htmlFor="csv-delim" className="text-base font-medium text-ink">Delimiter</label>
367
+ <Select value={delim} onValueChange={setDelim}>
368
+ <SelectTrigger id="csv-delim" className="w-full"><SelectValue /></SelectTrigger>
369
+ <SelectContent>{DELIMS.map(([v, l]) => <SelectItem key={v} value={v}>{l}{v === "auto" && parsed ? ` (${DELIMS.find((d) => d[0] === parsed.delimiter)?.[1].toLowerCase()})` : ""}</SelectItem>)}</SelectContent>
370
+ </Select>
371
+ </div>
372
+ <label className="flex items-center gap-2 self-end pb-2 text-base text-ink">
373
+ <Checkbox checked={header} onCheckedChange={(v) => setHeader(v === true)} />
374
+ First row contains column names
375
+ </label>
376
+ </div>
377
+ </div>
378
+ ) : parsed && (
379
+ <div className="grid gap-5 pb-1">
380
+ <div ref={summary} tabIndex={-1} className="outline-none">
381
+ {Object.keys(errors).length > 0 ? (
382
+ <Alert variant="destructive">
383
+ <CircleAlert />
384
+ <AlertTitle>{Object.keys(errors).length === 1 ? "1 required field has no column" : `${Object.keys(errors).length} required fields have no column`}</AlertTitle>
385
+ <AlertDescription>Choose a column for {Object.keys(errors).map((k) => targets.find((t) => t.id === k)?.label).join(" and ")} below, then import.</AlertDescription>
386
+ </Alert>
387
+ ) : problemRows > 0 ? (
388
+ <Alert variant="warning">
389
+ <TriangleAlert />
390
+ <AlertTitle>{summaryLines.join(" ")} {problemRows === 1 ? "Fix it in the preview, or choose what happens to it." : "Fix them in the preview, or choose what happens to them."}</AlertTitle>
391
+ <AlertDescription>
392
+ <RadioGroup value={badRows} onValueChange={(v) => setBadRows(v as "skip" | "empty")} aria-label="Rows with problems" className="mt-1 gap-2">
393
+ <label className="flex items-center gap-2"><RadioGroupItem value="skip" />Skip {n(problemRows)} {problemRows === 1 ? "row" : "rows"} with problems</label>
394
+ <label className="flex items-center gap-2"><RadioGroupItem value="empty" />Import them and leave the problem cells empty{problems.missing ? ` (${n(problems.missing)} without a required value ${problems.missing === 1 ? "is" : "are"} still skipped)` : ""}</label>
395
+ </RadioGroup>
396
+ </AlertDescription>
397
+ </Alert>
398
+ ) : (
399
+ <Alert><AlertTitle>All {n(total)} rows look right.</AlertTitle><AlertDescription>Check the preview, then import.</AlertDescription></Alert>
400
+ )}
401
+ </div>
402
+ <FieldMapping targets={targets} sources={parsed.sources} value={map} onChange={(m) => { setMap(m); setErrors({}) }} extra={extra} onExtraChange={setExtra} errors={errors} />
403
+ <section aria-labelledby="csv-preview" className="grid gap-2">
404
+ <div className="flex flex-wrap items-center gap-2">
405
+ <h3 id="csv-preview" className="flex-1 text-sm font-medium text-ink-2">Preview · {onlyProblems ? `${n(Math.min(20, problemRows))} of ${n(problemRows)} rows with problems` : `first ${n(previewRows.length)} of ${n(total)} rows`}</h3>
406
+ {problemRows > 0 && <label className="flex items-center gap-2 text-sm text-ink"><Switch checked={onlyProblems} onCheckedChange={setOnlyProblems} />Only rows with problems</label>}
407
+ </div>
408
+ <div className="overflow-x-auto rounded-sm border border-line">
409
+ <table className="w-max min-w-full border-separate border-spacing-0 text-base">
410
+ <thead>
411
+ <tr>
412
+ <th scope="col" className="sticky left-0 border-b border-line bg-surface-2 px-2 py-2 text-right text-sm font-medium text-ink-3">Row</th>
413
+ {mappedCols.map((c) => <th key={c.key} scope="col" className="border-b border-line bg-surface-2 px-3 py-2 text-left text-sm font-medium whitespace-nowrap text-ink-2">{c.label}</th>)}
414
+ </tr>
415
+ </thead>
416
+ <tbody>
417
+ {previewRows.map((r) => (
418
+ <tr key={r}>
419
+ <th scope="row" className="sticky left-0 border-b border-line bg-surface-2 px-2 text-right text-sm font-normal text-ink-3 tabular">{r + (header ? 2 : 1)}</th>
420
+ {mappedCols.map((c) => {
421
+ const p = problems.byRow.get(r)?.get(c.sid)
422
+ const k = `${r}:${c.sid}`
423
+ return (
424
+ <td key={c.key} className={cn("h-9 max-w-64 border-b border-line px-3 whitespace-nowrap", p ? "bg-bad-bg" : "text-ink")}>
425
+ {fixing === k ? (
426
+ <Input
427
+ autoFocus
428
+ defaultValue={cellOf(r, c.sid)}
429
+ aria-label={`Fix ${c.label} in row ${r + (header ? 2 : 1)}`}
430
+ onKeyDown={(e) => {
431
+ if (e.key === "Enter") { e.preventDefault(); setEdits((x) => ({ ...x, [k]: e.currentTarget.value })); setFixing(null) }
432
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setFixing(null) }
433
+ }}
434
+ onBlur={(e) => { setEdits((x) => ({ ...x, [k]: e.currentTarget.value })); setFixing(null) }}
435
+ />
436
+ ) : p ? (
437
+ <span className="flex items-center gap-control">
438
+ <CircleAlert className="size-3.5 shrink-0 text-bad" aria-hidden="true" />
439
+ <span className="text-sm font-medium text-bad">{p}</span>
440
+ <span className="min-w-0 truncate text-ink-2">{cellOf(r, c.sid)}</span>
441
+ <Button variant="ghost" size="xs" onClick={() => setFixing(k)} aria-label={`Fix ${c.label} in row ${r + (header ? 2 : 1)}`}>Fix</Button>
442
+ </span>
443
+ ) : <span className="block truncate">{cellOf(r, c.sid)}</span>}
444
+ </td>
445
+ )
446
+ })}
447
+ </tr>
448
+ ))}
449
+ </tbody>
450
+ </table>
451
+ </div>
452
+ </section>
453
+ </div>
454
+ )}
455
+ </div>
456
+
457
+ <DialogFooter>
458
+ {step === 2 && <Button variant="outline" className="sm:mr-auto" onClick={() => setStep(1)}>Back</Button>}
459
+ <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
460
+ {step === 1 ? <Button onClick={next}>Continue</Button> : <Button onClick={submit}>Import {n(importing)} {importing === 1 ? "row" : "rows"}</Button>}
461
+ </DialogFooter>
462
+ </DialogContent>
463
+ </Dialog>
464
+ )
465
+ }