@twoux/ui 0.0.0-stage → 0.1.0

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/SHADCN-DIFF.md +228 -0
  5. package/agent/AGENTS.md +358 -0
  6. package/agent/docs-md/accessibility.md +49 -0
  7. package/agent/docs-md/action-catalogue.md +81 -0
  8. package/agent/docs-md/actions.md +74 -0
  9. package/agent/docs-md/agent-activity-log.md +70 -0
  10. package/agent/docs-md/agent-permissions.md +82 -0
  11. package/agent/docs-md/agent-run.md +90 -0
  12. package/agent/docs-md/agents-on-behalf.md +74 -0
  13. package/agent/docs-md/ai-column.md +85 -0
  14. package/agent/docs-md/ai-origin-label.md +70 -0
  15. package/agent/docs-md/announcing-features.md +73 -0
  16. package/agent/docs-md/answer-actions.md +81 -0
  17. package/agent/docs-md/app-shell.md +114 -0
  18. package/agent/docs-md/artifact-card.md +89 -0
  19. package/agent/docs-md/ask-on-screen.md +72 -0
  20. package/agent/docs-md/assistant-page.md +100 -0
  21. package/agent/docs-md/assistant-panel.md +142 -0
  22. package/agent/docs-md/avatar.md +82 -0
  23. package/agent/docs-md/banner.md +106 -0
  24. package/agent/docs-md/board.md +163 -0
  25. package/agent/docs-md/breadcrumb.md +89 -0
  26. package/agent/docs-md/breakdown-table.md +97 -0
  27. package/agent/docs-md/bulk-action-bar.md +112 -0
  28. package/agent/docs-md/button.md +293 -0
  29. package/agent/docs-md/card.md +115 -0
  30. package/agent/docs-md/change-list.md +99 -0
  31. package/agent/docs-md/change-request.md +166 -0
  32. package/agent/docs-md/chart-area.md +87 -0
  33. package/agent/docs-md/chart-bar.md +100 -0
  34. package/agent/docs-md/chart-box-plot.md +88 -0
  35. package/agent/docs-md/chart-bullet.md +89 -0
  36. package/agent/docs-md/chart-candlestick.md +112 -0
  37. package/agent/docs-md/chart-card-line.md +120 -0
  38. package/agent/docs-md/chart-donut.md +90 -0
  39. package/agent/docs-md/chart-drill-down.md +72 -0
  40. package/agent/docs-md/chart-funnel.md +71 -0
  41. package/agent/docs-md/chart-heatmap.md +76 -0
  42. package/agent/docs-md/chart-histogram.md +81 -0
  43. package/agent/docs-md/chart-map.md +165 -0
  44. package/agent/docs-md/chart-mixed.md +88 -0
  45. package/agent/docs-md/chart-price.md +92 -0
  46. package/agent/docs-md/chart-radial-bars.md +109 -0
  47. package/agent/docs-md/chart-radial-lines.md +111 -0
  48. package/agent/docs-md/chart-sankey.md +104 -0
  49. package/agent/docs-md/chart-scatter.md +87 -0
  50. package/agent/docs-md/chart-share-bar.md +89 -0
  51. package/agent/docs-md/chart-treemap.md +98 -0
  52. package/agent/docs-md/chart-waterfall.md +93 -0
  53. package/agent/docs-md/checkbox.md +131 -0
  54. package/agent/docs-md/choosing-selection-control.md +71 -0
  55. package/agent/docs-md/citations-sources.md +79 -0
  56. package/agent/docs-md/citing-sources.md +63 -0
  57. package/agent/docs-md/clarify-before-acting.md +69 -0
  58. package/agent/docs-md/clarifying-question.md +92 -0
  59. package/agent/docs-md/code-block.md +174 -0
  60. package/agent/docs-md/colours.md +87 -0
  61. package/agent/docs-md/columns-menu.md +140 -0
  62. package/agent/docs-md/combobox.md +161 -0
  63. package/agent/docs-md/command-palette.md +126 -0
  64. package/agent/docs-md/confidence-note.md +87 -0
  65. package/agent/docs-md/configurable-dashboard.md +76 -0
  66. package/agent/docs-md/confirmation-dialog.md +93 -0
  67. package/agent/docs-md/connected-tools.md +80 -0
  68. package/agent/docs-md/connector-picker.md +127 -0
  69. package/agent/docs-md/consistency.md +53 -0
  70. package/agent/docs-md/content-density.md +61 -0
  71. package/agent/docs-md/conversation-history.md +78 -0
  72. package/agent/docs-md/create-record.md +80 -0
  73. package/agent/docs-md/csv-import.md +88 -0
  74. package/agent/docs-md/dashboard-items.md +78 -0
  75. package/agent/docs-md/data-export.md +61 -0
  76. package/agent/docs-md/data-grid.md +137 -0
  77. package/agent/docs-md/data-vis-colours.md +76 -0
  78. package/agent/docs-md/data-visualisation.md +76 -0
  79. package/agent/docs-md/date-picker.md +178 -0
  80. package/agent/docs-md/delete-record.md +75 -0
  81. package/agent/docs-md/dense-table.md +495 -0
  82. package/agent/docs-md/design-tokens.md +53 -0
  83. package/agent/docs-md/disabled-read-only.md +75 -0
  84. package/agent/docs-md/drag-and-drop.md +74 -0
  85. package/agent/docs-md/dropdown-menu.md +137 -0
  86. package/agent/docs-md/edit-record.md +73 -0
  87. package/agent/docs-md/empty-loading-error.md +78 -0
  88. package/agent/docs-md/empty-state.md +128 -0
  89. package/agent/docs-md/error-messages.md +75 -0
  90. package/agent/docs-md/error-state.md +123 -0
  91. package/agent/docs-md/export-menu.md +86 -0
  92. package/agent/docs-md/feedback-prompt.md +88 -0
  93. package/agent/docs-md/field-mapping.md +78 -0
  94. package/agent/docs-md/file-drop.md +110 -0
  95. package/agent/docs-md/filter-bar.md +138 -0
  96. package/agent/docs-md/filter-panel.md +103 -0
  97. package/agent/docs-md/filtering-search.md +79 -0
  98. package/agent/docs-md/follow-ups-starters.md +79 -0
  99. package/agent/docs-md/form-validation.md +95 -0
  100. package/agent/docs-md/generated-columns.md +65 -0
  101. package/agent/docs-md/generated-drafts-files.md +71 -0
  102. package/agent/docs-md/grouped-list.md +89 -0
  103. package/agent/docs-md/guide-cue.md +170 -0
  104. package/agent/docs-md/help-panel.md +103 -0
  105. package/agent/docs-md/help-system.md +74 -0
  106. package/agent/docs-md/iconography.md +49 -0
  107. package/agent/docs-md/inbox.md +108 -0
  108. package/agent/docs-md/inline-editing-validation.md +80 -0
  109. package/agent/docs-md/inline-input-in-answer.md +70 -0
  110. package/agent/docs-md/items-palette.md +116 -0
  111. package/agent/docs-md/keyboard-model.md +47 -0
  112. package/agent/docs-md/keyboard-navigation.md +68 -0
  113. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  114. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  115. package/agent/docs-md/labelling-ai-output.md +66 -0
  116. package/agent/docs-md/layout.md +47 -0
  117. package/agent/docs-md/list-table.md +254 -0
  118. package/agent/docs-md/loading-skeleton.md +91 -0
  119. package/agent/docs-md/managing-agents.md +71 -0
  120. package/agent/docs-md/message-composer.md +105 -0
  121. package/agent/docs-md/migrating.md +49 -0
  122. package/agent/docs-md/motion.md +93 -0
  123. package/agent/docs-md/navigation.md +76 -0
  124. package/agent/docs-md/notification-channels.md +65 -0
  125. package/agent/docs-md/notifications-decision.md +100 -0
  126. package/agent/docs-md/onboarding-step.md +114 -0
  127. package/agent/docs-md/order-book.md +108 -0
  128. package/agent/docs-md/page-header.md +96 -0
  129. package/agent/docs-md/pagination.md +88 -0
  130. package/agent/docs-md/pane-layout.md +105 -0
  131. package/agent/docs-md/period-comparison.md +66 -0
  132. package/agent/docs-md/plan-card.md +120 -0
  133. package/agent/docs-md/popover-rules.md +88 -0
  134. package/agent/docs-md/prompt-editor.md +90 -0
  135. package/agent/docs-md/propose-apply.md +65 -0
  136. package/agent/docs-md/provider-logo.md +97 -0
  137. package/agent/docs-md/radio.md +83 -0
  138. package/agent/docs-md/ranked-list.md +102 -0
  139. package/agent/docs-md/reasoning-progress.md +72 -0
  140. package/agent/docs-md/record-detail.md +71 -0
  141. package/agent/docs-md/regenerating-answers.md +70 -0
  142. package/agent/docs-md/run-status-bar.md +94 -0
  143. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  144. package/agent/docs-md/saved-views.md +149 -0
  145. package/agent/docs-md/saving-views.md +68 -0
  146. package/agent/docs-md/scheduler.md +258 -0
  147. package/agent/docs-md/secondary-panels.md +72 -0
  148. package/agent/docs-md/segmented-control.md +88 -0
  149. package/agent/docs-md/selection-bulk-actions.md +70 -0
  150. package/agent/docs-md/separator.md +75 -0
  151. package/agent/docs-md/setup-checklist.md +92 -0
  152. package/agent/docs-md/shape-elevation.md +55 -0
  153. package/agent/docs-md/sheet-grid.md +211 -0
  154. package/agent/docs-md/side-panel.md +134 -0
  155. package/agent/docs-md/sign-in.md +114 -0
  156. package/agent/docs-md/spacing.md +55 -0
  157. package/agent/docs-md/split-button.md +100 -0
  158. package/agent/docs-md/stat-compare.md +82 -0
  159. package/agent/docs-md/static-dashboard.md +75 -0
  160. package/agent/docs-md/status-badge.md +140 -0
  161. package/agent/docs-md/step-card.md +75 -0
  162. package/agent/docs-md/stepper.md +92 -0
  163. package/agent/docs-md/suggested-changes.md +91 -0
  164. package/agent/docs-md/switch.md +78 -0
  165. package/agent/docs-md/table-settings.md +81 -0
  166. package/agent/docs-md/tabs.md +85 -0
  167. package/agent/docs-md/tag-input.md +108 -0
  168. package/agent/docs-md/tag.md +79 -0
  169. package/agent/docs-md/task-builder.md +87 -0
  170. package/agent/docs-md/team-instructions.md +72 -0
  171. package/agent/docs-md/templates-gallery.md +85 -0
  172. package/agent/docs-md/text-input.md +160 -0
  173. package/agent/docs-md/thinking-steps.md +89 -0
  174. package/agent/docs-md/timeline.md +189 -0
  175. package/agent/docs-md/timestamp.md +84 -0
  176. package/agent/docs-md/timestamps.md +70 -0
  177. package/agent/docs-md/toast.md +117 -0
  178. package/agent/docs-md/tooltip.md +90 -0
  179. package/agent/docs-md/typography.md +62 -0
  180. package/agent/docs-md/undo-destructive.md +66 -0
  181. package/agent/docs-md/unsaved-changes.md +71 -0
  182. package/agent/docs-md/usage-meter.md +80 -0
  183. package/agent/docs-md/user-feedback.md +71 -0
  184. package/agent/docs-md/voice-and-tone.md +60 -0
  185. package/agent/docs-md/why-choice.md +93 -0
  186. package/agent/docs-md/workspace-onboarding.md +74 -0
  187. package/agent/docs-md/writing.md +48 -0
  188. package/agent/llms-full.txt +18369 -0
  189. package/agent/llms.txt +198 -0
  190. package/agent/skills/twoux-audit/SKILL.md +49 -0
  191. package/agent/skills/twoux-build/SKILL.md +39 -0
  192. package/agent/skills/twoux-design/SKILL.md +54 -0
  193. package/agent/skills/twoux-write/SKILL.md +32 -0
  194. package/bin/check.mjs +123 -0
  195. package/bin/docs.mjs +24 -0
  196. package/bin/guard.mjs +27 -0
  197. package/bin/init.mjs +113 -0
  198. package/bin/knowledge.mjs +87 -0
  199. package/bin/mcp.mjs +116 -0
  200. package/bin/theme.mjs +168 -0
  201. package/bin/twoux.mjs +25 -0
  202. package/package.json +80 -3
  203. package/registry/jobs.json +1028 -0
  204. package/registry/palette.json +770 -0
  205. package/registry/rules.json +913 -0
  206. package/src/components/agent-review.tsx +364 -0
  207. package/src/components/ai-basics.tsx +212 -0
  208. package/src/components/ai-work.tsx +304 -0
  209. package/src/components/ai.tsx +227 -0
  210. package/src/components/alert-dialog.tsx +203 -0
  211. package/src/components/alert.tsx +86 -0
  212. package/src/components/avatar.tsx +114 -0
  213. package/src/components/badge.tsx +59 -0
  214. package/src/components/board.tsx +397 -0
  215. package/src/components/breadcrumb.tsx +114 -0
  216. package/src/components/button-group.tsx +90 -0
  217. package/src/components/button.tsx +109 -0
  218. package/src/components/calendar.tsx +247 -0
  219. package/src/components/card.tsx +102 -0
  220. package/src/components/chart-map.tsx +336 -0
  221. package/src/components/charts-more.tsx +1331 -0
  222. package/src/components/charts-radial.tsx +328 -0
  223. package/src/components/charts.tsx +390 -0
  224. package/src/components/checkbox.tsx +40 -0
  225. package/src/components/code.tsx +139 -0
  226. package/src/components/collapsible.tsx +37 -0
  227. package/src/components/combobox.tsx +339 -0
  228. package/src/components/command-extras.tsx +11 -0
  229. package/src/components/command.tsx +196 -0
  230. package/src/components/data-grid.tsx +213 -0
  231. package/src/components/data-table.tsx +334 -0
  232. package/src/components/data-views.tsx +740 -0
  233. package/src/components/date-picker.tsx +686 -0
  234. package/src/components/dense-table.tsx +207 -0
  235. package/src/components/dialog.tsx +162 -0
  236. package/src/components/dropdown-menu.tsx +264 -0
  237. package/src/components/empty.tsx +114 -0
  238. package/src/components/feedback-prompt.tsx +80 -0
  239. package/src/components/field.tsx +254 -0
  240. package/src/components/fields.tsx +112 -0
  241. package/src/components/flow.tsx +840 -0
  242. package/src/components/guide-cue.tsx +273 -0
  243. package/src/components/inbox.tsx +113 -0
  244. package/src/components/input-group.tsx +176 -0
  245. package/src/components/input.tsx +26 -0
  246. package/src/components/kbd.tsx +34 -0
  247. package/src/components/label.tsx +27 -0
  248. package/src/components/labels.tsx +176 -0
  249. package/src/components/motion.tsx +136 -0
  250. package/src/components/onboarding.tsx +454 -0
  251. package/src/components/order-book.tsx +185 -0
  252. package/src/components/pane-layout.tsx +417 -0
  253. package/src/components/popover.tsx +96 -0
  254. package/src/components/provider-logos.data.ts +49 -0
  255. package/src/components/radio-group.tsx +52 -0
  256. package/src/components/scheduler.tsx +997 -0
  257. package/src/components/segmented-control.tsx +28 -0
  258. package/src/components/select.tsx +195 -0
  259. package/src/components/separator.tsx +31 -0
  260. package/src/components/sheet.tsx +148 -0
  261. package/src/components/shell.tsx +426 -0
  262. package/src/components/shortcut-sheet.tsx +83 -0
  263. package/src/components/skeleton.tsx +18 -0
  264. package/src/components/sonner.tsx +107 -0
  265. package/src/components/spinner.tsx +13 -0
  266. package/src/components/split-button.tsx +58 -0
  267. package/src/components/stepper.tsx +78 -0
  268. package/src/components/switch.tsx +43 -0
  269. package/src/components/table.tsx +126 -0
  270. package/src/components/tabs.tsx +118 -0
  271. package/src/components/tag-input.tsx +253 -0
  272. package/src/components/textarea.tsx +21 -0
  273. package/src/components/theme.tsx +80 -0
  274. package/src/components/timeline.tsx +408 -0
  275. package/src/components/toggle-group.tsx +87 -0
  276. package/src/components/toggle.tsx +54 -0
  277. package/src/components/tooltip.tsx +65 -0
  278. package/src/components/workspace-actions.tsx +294 -0
  279. package/src/components/workspace-import.tsx +465 -0
  280. package/src/components/workspace.tsx +1884 -0
  281. package/src/index.ts +80 -0
  282. package/src/lib/active-indicator.tsx +53 -0
  283. package/src/lib/chart-kit.tsx +72 -0
  284. package/src/lib/cn.ts +6 -0
  285. package/src/lib/shortcuts.tsx +61 -0
  286. package/src/lib/workspace.tsx +10 -0
  287. package/src/shadcn/alert-dialog.tsx +196 -0
  288. package/src/shadcn/alert.tsx +65 -0
  289. package/src/shadcn/avatar.tsx +108 -0
  290. package/src/shadcn/badge.tsx +47 -0
  291. package/src/shadcn/breadcrumb.tsx +108 -0
  292. package/src/shadcn/button-group.tsx +83 -0
  293. package/src/shadcn/button.tsx +63 -0
  294. package/src/shadcn/calendar.tsx +220 -0
  295. package/src/shadcn/card.tsx +91 -0
  296. package/src/shadcn/checkbox.tsx +31 -0
  297. package/src/shadcn/collapsible.tsx +33 -0
  298. package/src/shadcn/command.tsx +184 -0
  299. package/src/shadcn/dialog.tsx +158 -0
  300. package/src/shadcn/dropdown-menu.tsx +256 -0
  301. package/src/shadcn/empty.tsx +103 -0
  302. package/src/shadcn/field.tsx +248 -0
  303. package/src/shadcn/input-group.tsx +170 -0
  304. package/src/shadcn/input.tsx +20 -0
  305. package/src/shadcn/kbd.tsx +28 -0
  306. package/src/shadcn/label.tsx +23 -0
  307. package/src/shadcn/popover.tsx +88 -0
  308. package/src/shadcn/radio-group.tsx +44 -0
  309. package/src/shadcn/select.tsx +189 -0
  310. package/src/shadcn/separator.tsx +27 -0
  311. package/src/shadcn/sheet.tsx +142 -0
  312. package/src/shadcn/skeleton.tsx +13 -0
  313. package/src/shadcn/sonner.tsx +40 -0
  314. package/src/shadcn/switch.tsx +34 -0
  315. package/src/shadcn/table.tsx +115 -0
  316. package/src/shadcn/tabs.tsx +90 -0
  317. package/src/shadcn/textarea.tsx +17 -0
  318. package/src/shadcn/toggle-group.tsx +83 -0
  319. package/src/shadcn/toggle.tsx +46 -0
  320. package/src/shadcn/tooltip.tsx +56 -0
  321. package/src/styles/palette.css +127 -0
  322. package/src/styles/theme.css +35 -0
  323. package/src/styles/twoux.css +236 -0
  324. package/theme.json +14 -0
  325. package/README.md +0 -3
@@ -0,0 +1,213 @@
1
+ /**
2
+ * TwoUX composition: an editable grid for enriching many columns (WAI-ARIA grid pattern).
3
+ * One tab stop; arrows move between cells, Home/End to the row's ends; Enter or F2 edits, Esc cancels,
4
+ * Enter saves and stays, Tab saves and moves on. Validation per column; the message shows in the status line.
5
+ * Pinned columns and the header stay in view while scrolling; the totals row counts only the rows shown.
6
+ * Optional row selection: a checkbox column (Shift+Space toggles the active row) and the bulk action bar above the header.
7
+ */
8
+ import { Pencil } from "lucide-react"
9
+ import * as React from "react"
10
+ import { cn } from "../lib/cn"
11
+ import { Checkbox } from "./checkbox"
12
+ import { type BulkAction, BulkActionBar } from "./data-table"
13
+
14
+ export type GridColumn = {
15
+ id: string
16
+ label: string
17
+ /** px; pinned columns need one */
18
+ width: number
19
+ pin?: boolean
20
+ editable?: boolean
21
+ type?: "text" | "number"
22
+ /** Return a message to block the save. */
23
+ validate?: (value: string) => string | undefined
24
+ total?: "sum" | "average"
25
+ }
26
+ export type GridRow = { id: string } & Record<string, string | number>
27
+
28
+ export function DataGrid({
29
+ columns,
30
+ rows,
31
+ onCellChange,
32
+ "aria-label": label,
33
+ selectedRowIds,
34
+ onSelectedRowIdsChange,
35
+ bulkActions,
36
+ noun,
37
+ className,
38
+ }: {
39
+ columns: GridColumn[]
40
+ rows: GridRow[]
41
+ onCellChange: (rowId: string, columnId: string, value: string | number) => void
42
+ "aria-label": string
43
+ /** Selected rows. Pass with onSelectedRowIdsChange to add a checkbox column. */
44
+ selectedRowIds?: string[]
45
+ onSelectedRowIdsChange?: (ids: string[]) => void
46
+ /** Actions for the bulk action bar above the header while rows are selected; extras move into More. */
47
+ bulkActions?: BulkAction[]
48
+ /** Plural noun for the selection count: "2 listings selected". */
49
+ noun?: string
50
+ className?: string
51
+ }) {
52
+ const selectable = !!onSelectedRowIdsChange
53
+ const selected = selectedRowIds ?? []
54
+ const setSelected = (ids: string[]) => onSelectedRowIdsChange?.(ids)
55
+ const toggleRow = (id: string) => setSelected(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id])
56
+ const allChecked = rows.length > 0 && rows.every((r) => selected.includes(r.id)) ? true : rows.some((r) => selected.includes(r.id)) ? "indeterminate" : false
57
+ // The checkbox column is pinned at the left edge, so pinned data columns start after it (w-9, 36px).
58
+ const offset = selectable ? 36 : 0
59
+ const [pos, setPos] = React.useState({ r: 0, c: 0 })
60
+ const [editing, setEditing] = React.useState<string | null>(null)
61
+ const [error, setError] = React.useState<string>("")
62
+ const [status, setStatus] = React.useState("")
63
+ const grid = React.useRef<HTMLTableElement>(null)
64
+ const lefts = columns.reduce<number[]>((acc, c, i) => [...acc, i === 0 ? offset : acc[i - 1] + (columns[i - 1].pin ? columns[i - 1].width : 0)], [])
65
+ const totalsRow = columns.some((c) => c.total)
66
+
67
+ const cellEl = (r: number, c: number) => grid.current?.querySelector<HTMLElement>(`[data-cell="${r}-${c}"]`)
68
+ React.useEffect(() => { if (editing === null) cellEl(pos.r, pos.c)?.focus({ preventScroll: false }) }, [pos, editing])
69
+
70
+ const move = (r: number, c: number) => setPos({ r: Math.max(0, Math.min(rows.length - 1, r)), c: Math.max(0, Math.min(columns.length - 1, c)) })
71
+ const startEdit = (initial?: string) => {
72
+ const col = columns[pos.c]
73
+ if (!col.editable) { setStatus(`${col.label} can't be edited here.`); return }
74
+ setError("")
75
+ setEditing(initial ?? String(rows[pos.r][col.id] ?? ""))
76
+ }
77
+ const commit = (then: "stay" | "next") => {
78
+ const col = columns[pos.c], row = rows[pos.r]
79
+ const v = editing ?? ""
80
+ const msg = col.validate?.(v)
81
+ if (msg) { setError(msg); setStatus(`${col.label}: ${msg}`); return false }
82
+ onCellChange(row.id, col.id, col.type === "number" ? Number(v) : v)
83
+ setStatus(`Saved ${col.label} for ${row[columns[0].id]}.`)
84
+ setEditing(null)
85
+ if (then === "next") move(pos.r, pos.c + 1)
86
+ return true
87
+ }
88
+
89
+ const onKey = (e: React.KeyboardEvent) => {
90
+ if (editing !== null) {
91
+ if (e.key === "Escape") { e.preventDefault(); setEditing(null); setError(""); setStatus("Edit cancelled.") }
92
+ else if (e.key === "Enter") { e.preventDefault(); commit("stay") }
93
+ else if (e.key === "Tab") { e.preventDefault(); commit("next") }
94
+ return
95
+ }
96
+ const k = e.key
97
+ if (k === "ArrowRight") { e.preventDefault(); move(pos.r, pos.c + 1) }
98
+ else if (k === "ArrowLeft") { e.preventDefault(); move(pos.r, pos.c - 1) }
99
+ else if (k === "ArrowDown") { e.preventDefault(); move(pos.r + 1, pos.c) }
100
+ else if (k === "ArrowUp") { e.preventDefault(); move(pos.r - 1, pos.c) }
101
+ else if (k === "Home") { e.preventDefault(); move(e.ctrlKey ? 0 : pos.r, 0) }
102
+ else if (k === "End") { e.preventDefault(); move(e.ctrlKey ? rows.length - 1 : pos.r, columns.length - 1) }
103
+ else if (k === " " && e.shiftKey && selectable) { e.preventDefault(); toggleRow(rows[pos.r].id); setStatus(`${selected.includes(rows[pos.r].id) ? "Deselected" : "Selected"} ${rows[pos.r][columns[0].id]}.`) }
104
+ else if (k === "Enter" || k === "F2") { e.preventDefault(); startEdit() }
105
+ else if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && columns[pos.c].editable) { e.preventDefault(); startEdit(k) }
106
+ }
107
+
108
+ const fmt = (v: unknown) => (typeof v === "number" ? v.toLocaleString("en-GB") : String(v ?? ""))
109
+ const total = (c: GridColumn) => {
110
+ const nums = rows.map((r) => Number(r[c.id])).filter((n) => !Number.isNaN(n))
111
+ if (!nums.length) return ""
112
+ const sum = nums.reduce((a, b) => a + b, 0)
113
+ return c.total === "average" ? (sum / nums.length).toLocaleString("en-GB", { maximumFractionDigits: 1 }) : sum.toLocaleString("en-GB")
114
+ }
115
+ const stickyStyle = (i: number) => (columns[i].pin ? ({ "--l": `${lefts[i]}px` } as React.CSSProperties) : undefined)
116
+ const widthStyle = (i: number) => ({ ...stickyStyle(i), "--w": `${columns[i].width}px` }) as React.CSSProperties
117
+
118
+ return (
119
+ <div className={cn("grid overflow-hidden rounded-sm border border-line bg-surface", className)}>
120
+ {selectable && bulkActions?.length ? (
121
+ <BulkActionBar count={selected.length} noun={noun} onClear={() => setSelected([])} actions={bulkActions} />
122
+ ) : null}
123
+ <div className="max-h-96 overflow-auto">
124
+ <table ref={grid} role="grid" aria-label={label} aria-rowcount={rows.length + 1} aria-multiselectable={selectable || undefined} onKeyDown={onKey} className="w-max min-w-full border-separate border-spacing-0 text-base">
125
+ <thead>
126
+ <tr>
127
+ {selectable && (
128
+ <th scope="col" className="sticky top-0 left-0 z-30 h-9 w-9 min-w-9 border-b border-line bg-surface-2 pl-3">
129
+ <Checkbox aria-label="Select all rows" tabIndex={-1} className="translate-y-0.5" checked={allChecked} onCheckedChange={(v) => setSelected(v ? rows.map((r) => r.id) : [])} />
130
+ </th>
131
+ )}
132
+ {columns.map((c, i) => (
133
+ <th
134
+ key={c.id}
135
+ scope="col"
136
+ style={widthStyle(i)}
137
+ className={cn("sticky top-0 z-10 h-9 w-(--w) min-w-(--w) border-b border-line bg-surface-2 px-3 text-left text-sm font-medium whitespace-nowrap text-ink-2", c.pin && "left-(--l) z-20", c.type === "number" && "text-right")}
138
+ >
139
+ <span className="inline-flex items-center gap-1">{c.label}{c.editable && <Pencil className="size-3 text-ink-3" aria-label="(editable)" />}</span>
140
+ </th>
141
+ ))}
142
+ </tr>
143
+ </thead>
144
+ <tbody>
145
+ {rows.map((row, r) => (
146
+ <tr key={row.id} aria-selected={selectable ? selected.includes(row.id) : undefined} className="group/row">
147
+ {selectable && (
148
+ <td className={cn("sticky left-0 z-[2] h-9 w-9 min-w-9 border-b border-line pl-3", selected.includes(row.id) ? "bg-primary-soft" : "bg-surface")}>
149
+ <Checkbox aria-label={`Select ${row[columns[0].id]}`} tabIndex={-1} className="translate-y-0.5" checked={selected.includes(row.id)} onCheckedChange={() => toggleRow(row.id)} />
150
+ </td>
151
+ )}
152
+ {columns.map((c, ci) => {
153
+ const here = pos.r === r && pos.c === ci
154
+ const isEditing = here && editing !== null
155
+ return (
156
+ <td
157
+ key={c.id}
158
+ role="gridcell"
159
+ data-cell={`${r}-${ci}`}
160
+ tabIndex={here && !isEditing ? 0 : -1}
161
+ aria-readonly={!c.editable || undefined}
162
+ onMouseDown={() => { if (!isEditing) setPos({ r, c: ci }) }}
163
+ onDoubleClick={() => startEdit()}
164
+ style={widthStyle(ci)}
165
+ className={cn(
166
+ "h-9 w-(--w) min-w-(--w) border-b border-line px-3 whitespace-nowrap outline-none",
167
+ c.pin ? "sticky left-(--l) z-[1]" : "",
168
+ selected.includes(row.id) ? "bg-primary-soft" : "bg-surface",
169
+ c.type === "number" && "tabular text-right",
170
+ c.editable && !isEditing && "hover:bg-hover",
171
+ here && (isEditing && error ? "shadow-[inset_0_0_0_2px_var(--color-bad)]" : "shadow-[inset_0_0_0_2px_var(--color-primary)]"), isEditing && "bg-surface"
172
+ )}
173
+ >
174
+ {isEditing ? (
175
+ <input
176
+ autoFocus
177
+ value={editing}
178
+ aria-label={`${c.label} for ${row[columns[0].id]}`}
179
+ aria-invalid={!!error || undefined}
180
+ inputMode={c.type === "number" ? "decimal" : undefined}
181
+ onChange={(e) => { setEditing(e.target.value); setError("") }}
182
+ onBlur={() => { if (editing !== null && !commit("stay")) setEditing(null) }}
183
+ className={cn("h-full w-full bg-transparent outline-none", c.type === "number" && "text-right")}
184
+ />
185
+ ) : (
186
+ <span className="block truncate">{fmt(row[c.id])}</span>
187
+ )}
188
+ </td>
189
+ )
190
+ })}
191
+ </tr>
192
+ ))}
193
+ </tbody>
194
+ {totalsRow && (
195
+ <tfoot>
196
+ <tr>
197
+ {selectable && <td className="sticky bottom-0 left-0 z-[3] h-9 border-t border-line bg-surface-2" />}
198
+ {columns.map((c, i) => (
199
+ <td key={c.id} style={widthStyle(i)} className={cn("sticky bottom-0 h-9 border-t border-line bg-surface-2 px-3 text-sm font-medium whitespace-nowrap text-ink", c.pin && "left-(--l) z-[2]", c.type === "number" && "tabular text-right")}>
200
+ {i === 0 ? `Totals (${rows.length} rows shown)` : c.total ? `${c.total === "average" ? "Avg " : ""}${total(c)}` : ""}
201
+ </td>
202
+ ))}
203
+ </tr>
204
+ </tfoot>
205
+ )}
206
+ </table>
207
+ </div>
208
+ <p aria-live="polite" className={cn("min-h-8 border-t border-line px-3 py-2 text-sm", error ? "text-bad" : "text-ink-3")}>
209
+ {status || `Arrow keys move between cells. Enter or F2 edits, Esc cancels, Tab saves and moves on.${selectable ? " Shift+Space selects the row." : ""}`}
210
+ </p>
211
+ </div>
212
+ )
213
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * TwoUX compositions for the List table, built on the generated shadcn Table, Button and Badge.
3
+ * TableSortHead: only the sorted column shows its arrow; aria-sort sits on that header only.
4
+ * TablePagination: range and total, selection count first, Previous / "1 of 3" / Next.
5
+ * BulkActionBar: grey band directly above the table while rows are selected (actions, count, Clear selection); up to four actions,
6
+ * the rest and any that don't fit in More; Esc clears; filters and header stay in view.
7
+ * useRowKeyboard: J/K or arrows move row focus, X selects, ⇧J/⇧K extend, Enter opens, Esc clears.
8
+ */
9
+ import { ArrowUp, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
10
+ import * as React from "react"
11
+ import { cn } from "../lib/cn"
12
+ import { Button } from "./button"
13
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuShortcut, DropdownMenuTrigger } from "./dropdown-menu"
14
+ import { TableHead } from "./table"
15
+
16
+ type Sort = false | "asc" | "desc"
17
+
18
+ export function TableSortHead({
19
+ sorted,
20
+ onSort,
21
+ align = "left",
22
+ className,
23
+ children,
24
+ ...props
25
+ }: React.ComponentProps<typeof TableHead> & { sorted: Sort; onSort: () => void; align?: "left" | "right" }) {
26
+ return (
27
+ <TableHead
28
+ aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
29
+ className={cn("p-0", className)}
30
+ {...props}
31
+ >
32
+ <button
33
+ type="button"
34
+ onClick={onSort}
35
+ className={cn(
36
+ "group/sort flex h-9 w-full items-center gap-1 rounded-xs px-3 font-medium group-data-[size=dense]/table:h-7 group-data-[size=dense]/table:px-2 text-ink-2 transition-colors duration-(--duration-fast) ease-(--ease-out) hover:text-ink",
37
+ align === "right" ? "flex-row-reverse text-right" : "text-left",
38
+ sorted && "text-ink"
39
+ )}
40
+ >
41
+ {children}
42
+ <ArrowUp
43
+ aria-hidden="true"
44
+ className={cn(
45
+ "size-3 shrink-0 transition-[opacity,rotate] duration-(--duration-fast) ease-(--ease-out)",
46
+ sorted ? "opacity-100" : "opacity-0 group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60",
47
+ sorted === "desc" && "rotate-180"
48
+ )}
49
+ />
50
+ </button>
51
+ </TableHead>
52
+ )
53
+ }
54
+
55
+ export function TablePagination({
56
+ page,
57
+ pageCount,
58
+ pageSize,
59
+ total,
60
+ selected = 0,
61
+ noun = "rows",
62
+ onPageChange,
63
+ className,
64
+ }: {
65
+ /** Zero-based page index */
66
+ page: number
67
+ pageCount: number
68
+ pageSize: number
69
+ total: number
70
+ selected?: number
71
+ noun?: string
72
+ onPageChange: (page: number) => void
73
+ className?: string
74
+ }) {
75
+ const from = total === 0 ? 0 : page * pageSize + 1
76
+ const to = Math.min(total, (page + 1) * pageSize)
77
+ const fmt = (n: number) => n.toLocaleString("en-GB")
78
+ return (
79
+ <nav aria-label="Pagination" data-slot="table-pagination" className={cn("flex h-10 items-center gap-3 border-t border-line px-3 text-sm text-ink-2", className)}>
80
+ <p className="tabular min-w-0 truncate" aria-live="polite">
81
+ {selected > 0 && <span className="font-medium text-ink">{fmt(selected)} of {fmt(total)} selected · </span>}
82
+ Showing {fmt(from)}–{fmt(to)} of {fmt(total)} {noun}
83
+ </p>
84
+ <div className="ml-auto flex items-center gap-1">
85
+ <Button variant="ghost" size="icon-sm" aria-label="Previous page" disabled={page <= 0} onClick={() => onPageChange(page - 1)}>
86
+ <ChevronLeft />
87
+ </Button>
88
+ <span className="tabular px-1">{fmt(page + 1)} of {fmt(Math.max(1, pageCount))}</span>
89
+ <Button variant="ghost" size="icon-sm" aria-label="Next page" disabled={page >= pageCount - 1} onClick={() => onPageChange(page + 1)}>
90
+ <ChevronRight />
91
+ </Button>
92
+ </div>
93
+ </nav>
94
+ )
95
+ }
96
+
97
+ export type BulkAction = {
98
+ label: string
99
+ icon?: React.ReactNode
100
+ onSelect: () => void
101
+ /** Shown on the button, or beside the item once it moves into More. Keeps working in both places. */
102
+ shortcut?: string
103
+ /** Red text; put destructive actions last. */
104
+ destructive?: boolean
105
+ disabled?: boolean
106
+ }
107
+
108
+ /** Most actions shown as buttons before the rest move into More (selection-bulk-actions). */
109
+ const MAX_VISIBLE = 4
110
+ const GAP = 4
111
+
112
+ export function BulkActionBar({
113
+ count,
114
+ onClear,
115
+ noun,
116
+ actions = [],
117
+ size = "default",
118
+ className,
119
+ children,
120
+ }: {
121
+ count: number
122
+ onClear: () => void
123
+ /** Plural noun for the count: "2 listings selected". Without it: "2 selected". */
124
+ noun?: string
125
+ /** The bar's actions, in order. Up to four show as buttons; the rest, and any that don't fit, move into More. */
126
+ actions?: BulkAction[]
127
+ /** Match the table: "dense" for Table or SheetGrid size="dense". */
128
+ size?: "default" | "dense"
129
+ className?: string
130
+ /** Custom controls that always show before the actions, such as a menu button. */
131
+ children?: React.ReactNode
132
+ }) {
133
+ const ref = React.useRef<HTMLDivElement>(null)
134
+ const area = React.useRef<HTMLDivElement>(null)
135
+ const more = React.useRef<HTMLButtonElement>(null)
136
+ const [fit, setFit] = React.useState(Math.min(actions.length, MAX_VISIBLE))
137
+ const btn = size === "dense" ? "xs" : "sm"
138
+
139
+ React.useEffect(() => {
140
+ if (!count) return
141
+ const onKey = (e: KeyboardEvent) => {
142
+ if (e.key !== "Escape" || e.defaultPrevented) return
143
+ if ((e.target as HTMLElement | null)?.closest("[role=dialog], [role=alertdialog], [role=menu]")) return
144
+ onClear()
145
+ }
146
+ document.addEventListener("keydown", onKey)
147
+ return () => document.removeEventListener("keydown", onKey)
148
+ }, [count, onClear])
149
+
150
+ // Measure every action button (overflowed ones stay laid out, invisible, so they keep their width and shortcut)
151
+ // and show as many as fit beside More.
152
+ React.useLayoutEffect(() => {
153
+ const el = area.current
154
+ if (!el || !count) return
155
+ const measure = () => {
156
+ const widths = Array.from(el.querySelectorAll<HTMLElement>("[data-bulk-action]")).map((b) => b.offsetWidth)
157
+ const room = el.clientWidth
158
+ const moreW = (more.current?.offsetWidth ?? 0) + GAP
159
+ const total = widths.reduce((a, w) => a + w + GAP, 0)
160
+ let n = widths.length
161
+ if (n > MAX_VISIBLE || total > room) {
162
+ n = 0
163
+ let used = 0
164
+ while (n < widths.length && n < MAX_VISIBLE && used + widths[n] + GAP + moreW <= room) used += widths[n++] + GAP
165
+ }
166
+ setFit(n)
167
+ }
168
+ measure()
169
+ const ro = new ResizeObserver(measure)
170
+ ro.observe(el)
171
+ return () => ro.disconnect()
172
+ }, [count, actions.length, actions.map((a) => a.label).join("|"), btn])
173
+
174
+ if (!count) return null
175
+
176
+ // Toolbar pattern: one Tab stop, arrows move between buttons
177
+ const onKeyDown = (e: React.KeyboardEvent) => {
178
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return
179
+ const items = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled):not([data-overflow])") ?? [])
180
+ const i = items.indexOf(document.activeElement as HTMLButtonElement)
181
+ if (i < 0) return
182
+ e.preventDefault()
183
+ items[(i + (e.key === "ArrowRight" ? 1 : -1) + items.length) % items.length]?.focus()
184
+ }
185
+
186
+ const n = count.toLocaleString("en-GB")
187
+ const label = `${n} ${noun ? `${noun} ` : ""}selected`
188
+ const overflow = actions.slice(fit)
189
+ const hide = "invisible absolute top-0 left-0"
190
+
191
+ // A band directly above the table (below any filters), in the page flow: the header row stays visible.
192
+ // The count and Clear selection always stay in view; actions that don't fit move into More.
193
+ return (
194
+ <div
195
+ ref={ref}
196
+ role="toolbar"
197
+ aria-label={`Actions for ${label}`}
198
+ data-slot="bulk-action-bar"
199
+ data-size={size}
200
+ onKeyDown={onKeyDown}
201
+ className={cn(
202
+ "@container/bulk flex h-10 animate-fade-in items-center gap-3 border-b border-line bg-surface-2 px-3 data-[size=dense]:h-8 data-[size=dense]:px-2",
203
+ className
204
+ )}
205
+ >
206
+ {children && <div className="flex shrink-0 items-center gap-1">{children}</div>}
207
+ <div ref={area} className="relative flex h-full min-w-0 flex-1 items-center gap-1 overflow-hidden">
208
+ {actions.map((a, i) => (
209
+ <Button
210
+ key={a.label}
211
+ data-bulk-action=""
212
+ data-overflow={i >= fit || undefined}
213
+ aria-hidden={i >= fit || undefined}
214
+ tabIndex={i >= fit ? -1 : undefined}
215
+ variant="ghost"
216
+ size={btn}
217
+ shortcut={a.shortcut}
218
+ disabled={a.disabled}
219
+ onClick={a.onSelect}
220
+ className={cn("shrink-0", a.destructive && "text-bad hover:text-bad", i >= fit && hide)}
221
+ >
222
+ {a.icon}
223
+ {a.label}
224
+ </Button>
225
+ ))}
226
+ <DropdownMenu>
227
+ <DropdownMenuTrigger asChild>
228
+ <Button
229
+ ref={more}
230
+ variant="ghost"
231
+ size={btn}
232
+ aria-label={`More actions, ${overflow.length}`}
233
+ data-overflow={!overflow.length || undefined}
234
+ aria-hidden={!overflow.length || undefined}
235
+ tabIndex={overflow.length ? undefined : -1}
236
+ className={cn("shrink-0", !overflow.length && hide)}
237
+ >
238
+ More
239
+ <ChevronDown className="text-ink-3" />
240
+ </Button>
241
+ </DropdownMenuTrigger>
242
+ <DropdownMenuContent align="start">
243
+ {overflow.map((a) => (
244
+ <DropdownMenuItem key={a.label} variant={a.destructive ? "destructive" : "default"} disabled={a.disabled} onSelect={a.onSelect}>
245
+ {a.icon}
246
+ {a.label}
247
+ {a.shortcut && <DropdownMenuShortcut>{a.shortcut}</DropdownMenuShortcut>}
248
+ </DropdownMenuItem>
249
+ ))}
250
+ </DropdownMenuContent>
251
+ </DropdownMenu>
252
+ </div>
253
+ <div className="flex shrink-0 items-center gap-2">
254
+ <span className="tabular text-sm font-medium whitespace-nowrap text-ink" aria-live="polite">
255
+ {n} {noun && <span className="@max-lg/bulk:hidden">{noun} </span>}selected
256
+ </span>
257
+ <span aria-hidden="true" className="text-ink-3">·</span>
258
+ <Button
259
+ variant="ghost"
260
+ size={btn}
261
+ shortcut="Esc"
262
+ onClick={onClear}
263
+ aria-label="Clear selection"
264
+ className="@max-lg/bulk:[&_[data-slot=kbd]]:hidden"
265
+ >
266
+ <span>Clear<span className="@max-lg/bulk:hidden"> selection</span></span>
267
+ </Button>
268
+ </div>
269
+ </div>
270
+ )
271
+ }
272
+
273
+ export function useRowKeyboard({
274
+ count,
275
+ onToggle,
276
+ onOpen,
277
+ onClear,
278
+ selected = 0,
279
+ enabled = true,
280
+ }: {
281
+ count: number
282
+ /** value is true when extending a selection with ⇧J / ⇧K */
283
+ onToggle: (index: number, value?: boolean) => void
284
+ onOpen?: (index: number) => void
285
+ onClear?: () => void
286
+ /** How many rows are selected. Esc clears them only when there are some, so otherwise it reaches the side panel or dialog. */
287
+ selected?: number
288
+ /** Single-key shortcuts can be turned off in settings. */
289
+ enabled?: boolean
290
+ }) {
291
+ const [focused, setFocused] = React.useState(-1)
292
+ const ref = React.useRef<HTMLTableSectionElement>(null)
293
+
294
+ React.useEffect(() => {
295
+ if (focused >= count) setFocused(count - 1)
296
+ }, [count, focused])
297
+
298
+ React.useEffect(() => {
299
+ ref.current?.querySelector<HTMLElement>(`[data-row-index="${focused}"]`)?.scrollIntoView({ block: "nearest" })
300
+ }, [focused])
301
+
302
+ const onKeyDown = (e: React.KeyboardEvent) => {
303
+ if (!enabled || e.metaKey || e.ctrlKey || e.altKey) return
304
+ if ((e.target as HTMLElement).closest("input:not([type=checkbox]), textarea, [contenteditable=true]")) return
305
+ const key = e.key.toLowerCase()
306
+ const move = (d: number) => setFocused((f) => Math.max(0, Math.min(count - 1, (f < 0 ? (d > 0 ? -1 : count) : f) + d)))
307
+ if ((key === "j" && !e.shiftKey) || e.key === "ArrowDown") move(1)
308
+ else if ((key === "k" && !e.shiftKey) || e.key === "ArrowUp") move(-1)
309
+ else if ((key === "j" || key === "k") && e.shiftKey) {
310
+ const next = Math.max(0, Math.min(count - 1, focused + (key === "j" ? 1 : -1)))
311
+ if (focused >= 0) onToggle(focused, true)
312
+ onToggle(next, true)
313
+ setFocused(next)
314
+ }
315
+ else if (key === "x" && focused >= 0) onToggle(focused)
316
+ else if (e.key === "Enter" && focused >= 0 && onOpen) onOpen(focused)
317
+ else if (e.key === "Escape" && onClear && selected > 0) onClear()
318
+ else return
319
+ e.preventDefault()
320
+ }
321
+
322
+ return {
323
+ focused,
324
+ setFocused,
325
+ /** Spread on TableBody */
326
+ bodyProps: { ref, tabIndex: 0, onKeyDown },
327
+ /** Spread on each TableRow */
328
+ rowProps: (index: number) => ({
329
+ "data-row-index": index,
330
+ "data-focused": focused === index || undefined,
331
+ onMouseDown: () => setFocused(index),
332
+ }),
333
+ }
334
+ }