@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,397 @@
1
+ /**
2
+ * TwoUX composition after an open-source board layout (Apache 2.0, see NOTICE.md): a grid of
3
+ * self-contained items people can reorder and resize. Built on TwoUX's Card, Button and Dropdown menu.
4
+ * Every drag has a keyboard and a menu alternative (WCAG 2.1.1, 2.5.7), and every change is announced.
5
+ */
6
+ import { Ellipsis, GripVertical, MoveHorizontal, Plus, Search } from "lucide-react"
7
+ import * as React from "react"
8
+ import { createPortal } from "react-dom"
9
+ import { cn } from "../lib/cn"
10
+ import { Button } from "./button"
11
+ import { Input } from "./input"
12
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
13
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "./dropdown-menu"
14
+
15
+ export type BoardItemData = { id: string; title: string; columnSpan?: number }
16
+ const PALETTE_MIME = "application/x-twoux-board-item"
17
+ const COLUMNS = 4
18
+ const SPAN: Record<number, string> = { 1: "", 2: "@2xl:col-span-2", 3: "@2xl:col-span-2 @5xl:col-span-3", 4: "@2xl:col-span-2 @5xl:col-span-4" }
19
+ const reducedMotion = () => typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
20
+
21
+ type Drag = { id: string; from: number; over: number; x: number; y: number; w: number; h: number; dx: number; dy: number }
22
+ type BoardCtx = {
23
+ items: BoardItemData[]
24
+ move: (id: string, to: number) => void
25
+ resize: (id: string, span: number) => void
26
+ remove: (id: string) => void
27
+ announce: (text: string) => void
28
+ startDrag: (id: string, e: React.PointerEvent, card: HTMLElement) => void
29
+ draggingId: string | null
30
+ settledId: string | null
31
+ }
32
+ const Ctx = React.createContext<BoardCtx | null>(null)
33
+ const useBoard = () => {
34
+ const c = React.useContext(Ctx)
35
+ if (!c) throw new Error("BoardItem must be inside Board")
36
+ return c
37
+ }
38
+
39
+ export function Board({
40
+ items,
41
+ onItemsChange,
42
+ renderItem,
43
+ empty,
44
+ onAddFromPalette,
45
+ className,
46
+ "aria-label": label = "Dashboard",
47
+ }: {
48
+ items: BoardItemData[]
49
+ onItemsChange: (items: BoardItemData[]) => void
50
+ renderItem: (item: BoardItemData) => React.ReactNode
51
+ /** Shown when there are no items, e.g. an Empty state with "Add items". */
52
+ empty: React.ReactNode
53
+ /** An item dragged in from the ItemsPalette was dropped at this position. */
54
+ onAddFromPalette?: (paletteId: string, index: number) => void
55
+ className?: string
56
+ "aria-label"?: string
57
+ }) {
58
+ const [message, setMessage] = React.useState("")
59
+ const [drag, setDragState] = React.useState<Drag | null>(null)
60
+ const dragRef = React.useRef<Drag | null>(null)
61
+ const setDrag = (d: Drag | null) => { dragRef.current = d; setDragState(d) }
62
+ const [paletteOver, setPaletteOver] = React.useState<number | null>(null)
63
+ const [settledId, setSettledId] = React.useState<string | null>(null)
64
+ const listRef = React.useRef<HTMLUListElement>(null)
65
+ const announce = React.useCallback((t: string) => setMessage((m) => (m === t ? `${t} ` : t)), [])
66
+
67
+ const settle = (id: string) => { setSettledId(id); setTimeout(() => setSettledId((s) => (s === id ? null : s)), 900) }
68
+ const move = (id: string, to: number) => {
69
+ const from = items.findIndex((i) => i.id === id)
70
+ const clamped = Math.max(0, Math.min(items.length - 1, to))
71
+ if (from < 0 || from === clamped) return
72
+ const next = [...items]
73
+ const [it] = next.splice(from, 1)
74
+ next.splice(clamped, 0, it)
75
+ onItemsChange(next)
76
+ announce(`${it.title} moved to position ${clamped + 1} of ${items.length}.`)
77
+ }
78
+
79
+ // Which slot is under the pointer, against the layout measured when the drag started (so the moving slot can't chase itself).
80
+ const snapshot = () => Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-cell-id]") ?? []).map((c) => ({ id: c.dataset.cellId!, r: c.getBoundingClientRect() }))
81
+ const slotFrom = (cells: { id: string; r: DOMRect }[], x: number, y: number) => {
82
+ let best = { i: cells.length, d: Number.POSITIVE_INFINITY }
83
+ cells.forEach(({ r }, i) => {
84
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) { best = { i, d: -1 }; return }
85
+ if (best.d < 0) return
86
+ const d = Math.hypot(x - (r.left + r.width / 2), y - (r.top + r.height / 2))
87
+ if (d < best.d) best = { i: i === cells.length - 1 && (y > r.bottom || x > r.right) ? cells.length : i, d }
88
+ })
89
+ return best.i
90
+ }
91
+
92
+ const startDrag = (id: string, e: React.PointerEvent, card: HTMLElement) => {
93
+ if (e.button !== 0) return
94
+ const r = card.getBoundingClientRect()
95
+ const from = items.findIndex((i) => i.id === id)
96
+ const sx = e.clientX, sy = e.clientY
97
+ const cells = snapshot()
98
+ let started = false
99
+ const onMove = (ev: PointerEvent) => {
100
+ if (!started && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 4) return // a click on the handle is not a drag
101
+ if (!started) { started = true; announce(`Dragging ${items[from].title}.`) }
102
+ setDrag({ id, from, over: Math.min(slotFrom(cells, ev.clientX, ev.clientY), items.length - 1), x: ev.clientX, y: ev.clientY, w: r.width, h: r.height, dx: sx - r.left, dy: sy - r.top })
103
+ }
104
+ const finish = (commit: boolean) => {
105
+ window.removeEventListener("pointermove", onMove)
106
+ window.removeEventListener("pointerup", onUp)
107
+ window.removeEventListener("keydown", onKey)
108
+ const d = dragRef.current
109
+ setDrag(null)
110
+ if (d && commit && d.over !== d.from) { move(id, d.over); settle(id) }
111
+ else if (d && !commit) announce(`Move cancelled. ${items[from].title} is back at position ${from + 1}.`)
112
+ }
113
+ const onUp = () => finish(true)
114
+ const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") { ev.preventDefault(); finish(false) } }
115
+ window.addEventListener("pointermove", onMove)
116
+ window.addEventListener("pointerup", onUp)
117
+ window.addEventListener("keydown", onKey)
118
+ }
119
+
120
+ // Items in the order they'd have if dropped now; the dragged one becomes the landing slot.
121
+ const preview = React.useMemo(() => {
122
+ if (!drag) return items
123
+ const rest = items.filter((i) => i.id !== drag.id)
124
+ return [...rest.slice(0, drag.over), items[drag.from], ...rest.slice(drag.over)]
125
+ }, [items, drag])
126
+
127
+ // FLIP: cells glide to their new places instead of jumping.
128
+ const rects = React.useRef(new Map<string, DOMRect>())
129
+ React.useLayoutEffect(() => {
130
+ const cells = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-cell-id]") ?? [])
131
+ const reduce = reducedMotion()
132
+ for (const c of cells) {
133
+ const id = c.dataset.cellId!
134
+ const now = c.getBoundingClientRect()
135
+ const before = rects.current.get(id)
136
+ if (before && !reduce && (before.left !== now.left || before.top !== now.top)) {
137
+ c.animate([{ transform: `translate(${before.left - now.left}px, ${before.top - now.top}px)` }, { transform: "none" }], { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)" })
138
+ }
139
+ rects.current.set(id, now)
140
+ }
141
+ })
142
+
143
+ const draggingItem = drag ? items[drag.from] : null
144
+ const active = !!drag || paletteOver !== null
145
+ const value: BoardCtx = {
146
+ items,
147
+ move,
148
+ resize: (id, span) => {
149
+ const sp = Math.max(1, Math.min(COLUMNS, span))
150
+ const it = items.find((i) => i.id === id)
151
+ if (!it || (it.columnSpan ?? 1) === sp) return
152
+ onItemsChange(items.map((i) => (i.id === id ? { ...i, columnSpan: sp } : i)))
153
+ announce(`${it.title} now ${sp} of ${COLUMNS} columns wide.`)
154
+ settle(id)
155
+ },
156
+ remove: (id) => {
157
+ const it = items.find((i) => i.id === id)
158
+ onItemsChange(items.filter((i) => i.id !== id))
159
+ if (it) announce(`${it.title} removed.`)
160
+ },
161
+ announce,
162
+ startDrag,
163
+ draggingId: drag?.id ?? null,
164
+ settledId,
165
+ }
166
+
167
+ const paletteCells = React.useRef<{ id: string; r: DOMRect }[] | null>(null)
168
+ const onPaletteOver = (e: React.DragEvent) => {
169
+ if (!e.dataTransfer.types.includes(PALETTE_MIME)) return
170
+ e.preventDefault()
171
+ e.dataTransfer.dropEffect = "copy"
172
+ paletteCells.current ??= snapshot()
173
+ const at = slotFrom(paletteCells.current, e.clientX, e.clientY)
174
+ if (at !== paletteOver) setPaletteOver(at)
175
+ }
176
+ const onPaletteDrop = (e: React.DragEvent) => {
177
+ const pid = e.dataTransfer.getData(PALETTE_MIME)
178
+ if (pid && onAddFromPalette) { e.preventDefault(); onAddFromPalette(pid, paletteOver ?? items.length) }
179
+ paletteCells.current = null
180
+ setPaletteOver(null)
181
+ }
182
+
183
+ const slot = (key: string, span = 1, h?: number) => (
184
+ <li
185
+ key={key}
186
+ aria-hidden="true"
187
+ className={cn("min-h-24 rounded-sm border-2 border-dashed border-primary/50 bg-primary-soft/60 h-(--slot-h)", SPAN[span])}
188
+ style={h ? ({ "--slot-h": `${h}px` } as React.CSSProperties) : undefined}
189
+ />
190
+ )
191
+
192
+ return (
193
+ <Ctx.Provider value={value}>
194
+ <section
195
+ aria-label={label}
196
+ data-slot="board"
197
+ data-dragging={active || undefined}
198
+ className={cn("@container rounded-md transition-[background-color,box-shadow] duration-(--duration-fast)", active && "bg-primary-soft/30 ring-1 ring-primary/25 ring-offset-4 ring-offset-bg", className)}
199
+ onDragOver={onPaletteOver}
200
+ onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { paletteCells.current = null; setPaletteOver(null) } }}
201
+ onDrop={onPaletteDrop}
202
+ >
203
+ {items.length === 0 && paletteOver === null ? (
204
+ empty
205
+ ) : (
206
+ <ul ref={listRef} className="grid grid-cols-1 gap-3 @2xl:grid-cols-2 @5xl:grid-cols-4">
207
+ {preview.map((it, i) => {
208
+ if (drag && it.id === drag.id) return slot(`slot-${it.id}`, it.columnSpan ?? 1, drag.h)
209
+ return (
210
+ <React.Fragment key={it.id}>
211
+ {paletteOver === i && slot("palette-slot")}
212
+ <li data-cell-id={it.id} data-slot="board-cell" className={cn("min-w-0", SPAN[it.columnSpan ?? 1])}>
213
+ {renderItem(it)}
214
+ </li>
215
+ </React.Fragment>
216
+ )
217
+ })}
218
+ {paletteOver !== null && paletteOver >= preview.length && slot("palette-slot")}
219
+ </ul>
220
+ )}
221
+ {drag && draggingItem && typeof document !== "undefined" && createPortal(
222
+ <div
223
+ aria-hidden="true"
224
+ className="pointer-events-none fixed z-50 w-(--w) rotate-1 cursor-grabbing opacity-95 shadow-pop left-(--x) top-(--y) [&_[data-slot=board-item]]:shadow-pop"
225
+ style={{ "--w": `${drag.w}px`, "--x": `${drag.x - drag.dx}px`, "--y": `${drag.y - drag.dy}px` } as React.CSSProperties}
226
+ >
227
+ {renderItem(draggingItem)}
228
+ </div>,
229
+ document.body,
230
+ )}
231
+ <div aria-live="assertive" className="sr-only">{message}</div>
232
+ </section>
233
+ </Ctx.Provider>
234
+ )
235
+ }
236
+
237
+ export function BoardItem({
238
+ id,
239
+ header,
240
+ description,
241
+ settings,
242
+ actions,
243
+ footer,
244
+ disableContentPaddings = false,
245
+ level = 3,
246
+ className,
247
+ children,
248
+ }: {
249
+ id: string
250
+ header: string
251
+ /** Heading level of the header. Use 2 when the board sits straight under the page's h1. */
252
+ level?: 2 | 3 | 4
253
+ description?: string
254
+ /** Extra items for the item's menu; Move, Resize and Remove are always there. */
255
+ settings?: React.ReactNode
256
+ /** One or two quiet buttons in the header, before the menu (Refresh, View in…). */
257
+ actions?: React.ReactNode
258
+ footer?: React.ReactNode
259
+ disableContentPaddings?: boolean
260
+ className?: string
261
+ children: React.ReactNode
262
+ }) {
263
+ const board = useBoard()
264
+ const index = board.items.findIndex((i) => i.id === id)
265
+ const span = board.items[index]?.columnSpan ?? 1
266
+ const [grabbed, setGrabbed] = React.useState<{ from: number } | null>(null)
267
+ const handle = React.useRef<HTMLButtonElement>(null)
268
+ const card = React.useRef<HTMLElement>(null)
269
+ const headingId = React.useId()
270
+ const settled = board.settledId === id
271
+
272
+ // Keyboard reorder: Enter or Space picks up, arrows move, Enter drops, Esc puts it back.
273
+ const onHandleKey = (e: React.KeyboardEvent) => {
274
+ const k = e.key
275
+ if (!grabbed && (k === "Enter" || k === " ")) {
276
+ e.preventDefault()
277
+ setGrabbed({ from: index })
278
+ board.announce(`Picked up ${header}, position ${index + 1} of ${board.items.length}. Use arrow keys to move, Enter to drop, Escape to cancel.`)
279
+ return
280
+ }
281
+ if (!grabbed) return
282
+ if (["ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown"].includes(k)) {
283
+ e.preventDefault()
284
+ board.move(id, index + (k === "ArrowLeft" || k === "ArrowUp" ? -1 : 1))
285
+ } else if (k === "Enter" || k === " ") {
286
+ e.preventDefault()
287
+ setGrabbed(null)
288
+ board.announce(`${header} dropped at position ${index + 1}.`)
289
+ } else if (k === "Escape") {
290
+ e.preventDefault()
291
+ board.move(id, grabbed.from)
292
+ setGrabbed(null)
293
+ board.announce(`Move cancelled. ${header} is back at position ${grabbed.from + 1}.`)
294
+ }
295
+ }
296
+ React.useEffect(() => { if (grabbed) handle.current?.focus() }, [grabbed, index])
297
+
298
+ return (
299
+ <article
300
+ ref={card}
301
+ aria-labelledby={headingId}
302
+ data-slot="board-item"
303
+ data-grabbed={grabbed ? "" : undefined}
304
+ className={cn(
305
+ "group/item flex h-full flex-col rounded-sm border bg-surface shadow-card transition-[box-shadow,border-color] duration-(--duration-base) ease-(--ease-out)",
306
+ grabbed ? "border-primary shadow-pop ring-3 ring-primary/20" : settled ? "border-primary ring-3 ring-primary/20" : "border-line hover:border-line-strong",
307
+ className
308
+ )}
309
+ >
310
+ <header className="flex items-start gap-1 py-2 pr-2 pl-1">
311
+ <Tooltip>
312
+ <TooltipTrigger asChild>
313
+ <button
314
+ ref={handle}
315
+ type="button"
316
+ aria-label={`Move ${header}`}
317
+ aria-description="Drag to move, or press Enter and use the arrow keys."
318
+ aria-pressed={!!grabbed}
319
+ onKeyDown={onHandleKey}
320
+ onPointerDown={(e) => { if (card.current && !grabbed) board.startDrag(id, e, card.current) }}
321
+ className="relative mt-px grid size-6 shrink-0 cursor-grab touch-none place-items-center rounded-xs text-ink-3 transition-colors group-hover/item:bg-hover group-hover/item:text-ink-2 hover:text-ink active:cursor-grabbing aria-pressed:bg-primary-soft aria-pressed:text-primary-ink"
322
+ >
323
+ <GripVertical className="size-3.5" aria-hidden="true" />
324
+ </button>
325
+ </TooltipTrigger>
326
+ <TooltipContent>Drag to move, or press Enter to use the arrow keys</TooltipContent>
327
+ </Tooltip>
328
+ <div className="grid min-w-0 flex-1 gap-0.5 pt-0.5">
329
+ {React.createElement(`h${level}`, { id: headingId, className: "truncate text-base font-semibold text-ink" }, header)}
330
+ {description && <p className="truncate text-sm text-ink-3">{description}</p>}
331
+ </div>
332
+ {actions && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
333
+ <DropdownMenu>
334
+ <DropdownMenuTrigger asChild>
335
+ <Button variant="ghost" size="icon-sm" aria-label={`Settings for ${header}`}><Ellipsis /></Button>
336
+ </DropdownMenuTrigger>
337
+ <DropdownMenuContent align="end">
338
+ {settings}
339
+ {settings && <DropdownMenuSeparator />}
340
+ <DropdownMenuItem disabled={index === 0} onSelect={() => board.move(id, index - 1)}>Move earlier</DropdownMenuItem>
341
+ <DropdownMenuItem disabled={index === board.items.length - 1} onSelect={() => board.move(id, index + 1)}>Move later</DropdownMenuItem>
342
+ <DropdownMenuItem disabled={span >= COLUMNS} onSelect={() => board.resize(id, span + 1)}><MoveHorizontal />Wider</DropdownMenuItem>
343
+ <DropdownMenuItem disabled={span <= 1} onSelect={() => board.resize(id, span - 1)}>Narrower</DropdownMenuItem>
344
+ <DropdownMenuSeparator />
345
+ <DropdownMenuItem variant="destructive" onSelect={() => board.remove(id)}>Remove from dashboard</DropdownMenuItem>
346
+ </DropdownMenuContent>
347
+ </DropdownMenu>
348
+ </header>
349
+ <div className={cn("min-h-0 flex-1", !disableContentPaddings && "px-3 pb-3")}>{children}</div>
350
+ {footer && <footer className="border-t border-line px-3 py-2 text-sm text-ink-2">{footer}</footer>}
351
+ </article>
352
+ )
353
+ }
354
+
355
+ /* ---------------------------------------------------------------- items palette */
356
+
357
+ export type PaletteItem = { id: string; title: string; description: string; type: "Key metrics" | "Chart" | "List" | "Status" | "Links" }
358
+
359
+ /** Items not yet on the board: title, one line, type. Add with the button (keyboard and one click) or drag onto the board. */
360
+ export function ItemsPalette({ items, onAdd, className }: { items: PaletteItem[]; onAdd: (id: string) => void; className?: string }) {
361
+ const [q, setQ] = React.useState("")
362
+ const shown = items.filter((i) => `${i.title} ${i.description}`.toLowerCase().includes(q.toLowerCase()))
363
+ return (
364
+ <div data-slot="items-palette" className={cn("grid content-start gap-2", className)}>
365
+ {items.length > 10 && (
366
+ <div className="relative">
367
+ <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" />
368
+ <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Find an item" aria-label="Find an item" className="pl-8" />
369
+ </div>
370
+ )}
371
+ {items.length === 0 ? (
372
+ <p className="py-6 text-center text-base text-ink-2">All items are on your dashboard.</p>
373
+ ) : shown.length === 0 ? (
374
+ <p className="flex items-center justify-center gap-2 py-6 text-base text-ink-2">No items match. <Button variant="ghost" size="xs" onClick={() => setQ("")}>Clear search</Button></p>
375
+ ) : (
376
+ <ul className="grid gap-2" aria-label="Items you can add">
377
+ {shown.map((it) => (
378
+ <li
379
+ key={it.id}
380
+ draggable
381
+ onDragStart={(e) => { e.dataTransfer.setData(PALETTE_MIME, it.id); e.dataTransfer.effectAllowed = "copy" }}
382
+ className="group/pal flex cursor-grab items-start gap-2 rounded-sm border border-line bg-surface p-2 shadow-xs transition-[border-color,box-shadow] duration-(--duration-fast) hover:border-line-strong hover:shadow-card active:cursor-grabbing"
383
+ >
384
+ <GripVertical className="mt-0.5 size-3.5 shrink-0 text-ink-3 transition-colors group-hover/pal:text-ink-2" aria-hidden="true" />
385
+ <span className="grid min-w-0 flex-1 gap-0.5">
386
+ <span className="text-base font-medium text-ink">{it.title}</span>
387
+ <span className="text-sm text-ink-2">{it.description}</span>
388
+ <span className="text-xs text-ink-3">{it.type}</span>
389
+ </span>
390
+ <Button variant="outline" size="xs" onClick={() => onAdd(it.id)} aria-label={`Add ${it.title} to dashboard`}><Plus />Add</Button>
391
+ </li>
392
+ ))}
393
+ </ul>
394
+ )}
395
+ </div>
396
+ )
397
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "breadcrumb" by scripts/tailor-shadcn.mjs. Do not edit by hand.
3
+ * TwoUX changes:
4
+ * - One line in the top bar: never wraps; the current page truncates last and reads darker
5
+ * - Separator chevrons are small and muted
6
+ */
7
+ import * as React from "react"
8
+ import { cn } from "../lib/cn"
9
+ import { ChevronRight, MoreHorizontal } from "lucide-react"
10
+ import { Slot } from "radix-ui"
11
+
12
+ function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
13
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
14
+ }
15
+
16
+ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
17
+ return (
18
+ <ol
19
+ data-slot="breadcrumb-list"
20
+ className={cn(
21
+ "flex min-w-0 flex-nowrap items-center gap-1.5 text-base text-ink-2",
22
+ className
23
+ )}
24
+ {...props}
25
+ />
26
+ )
27
+ }
28
+
29
+ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
30
+ return (
31
+ <li
32
+ data-slot="breadcrumb-item"
33
+ className={cn("inline-flex min-w-0 items-center gap-1.5", className)}
34
+ {...props}
35
+ />
36
+ )
37
+ }
38
+
39
+ function BreadcrumbLink({
40
+ asChild,
41
+ className,
42
+ ...props
43
+ }: React.ComponentProps<"a"> & {
44
+ asChild?: boolean
45
+ }) {
46
+ const Comp = asChild ? Slot.Root : "a"
47
+
48
+ return (
49
+ <Comp
50
+ data-slot="breadcrumb-link"
51
+ className={cn("rounded-xs whitespace-nowrap transition-colors duration-(--duration-fast) hover:text-ink", className)}
52
+ {...props}
53
+ />
54
+ )
55
+ }
56
+
57
+ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
58
+ return (
59
+ <span
60
+ data-slot="breadcrumb-page"
61
+ role="link"
62
+ aria-disabled="true"
63
+ aria-current="page"
64
+ className={cn("truncate font-medium text-ink", className)}
65
+ {...props}
66
+ />
67
+ )
68
+ }
69
+
70
+ function BreadcrumbSeparator({
71
+ children,
72
+ className,
73
+ ...props
74
+ }: React.ComponentProps<"li">) {
75
+ return (
76
+ <li
77
+ data-slot="breadcrumb-separator"
78
+ role="presentation"
79
+ aria-hidden="true"
80
+ className={cn("text-ink-3 [&>svg]:size-3", className)}
81
+ {...props}
82
+ >
83
+ {children ?? <ChevronRight />}
84
+ </li>
85
+ )
86
+ }
87
+
88
+ function BreadcrumbEllipsis({
89
+ className,
90
+ ...props
91
+ }: React.ComponentProps<"span">) {
92
+ return (
93
+ <span
94
+ data-slot="breadcrumb-ellipsis"
95
+ role="presentation"
96
+ aria-hidden="true"
97
+ className={cn("flex size-6 items-center justify-center", className)}
98
+ {...props}
99
+ >
100
+ <MoreHorizontal className="size-4" />
101
+ <span className="sr-only">More</span>
102
+ </span>
103
+ )
104
+ }
105
+
106
+ export {
107
+ Breadcrumb,
108
+ BreadcrumbList,
109
+ BreadcrumbItem,
110
+ BreadcrumbLink,
111
+ BreadcrumbPage,
112
+ BreadcrumbSeparator,
113
+ BreadcrumbEllipsis,
114
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "button-group" by scripts/tailor-shadcn.mjs. Do not edit by hand.
3
+ * TwoUX changes:
4
+ * - Text segment on the quiet surface with the light edge, 12px padding and 14px icons, in proportion with compact buttons
5
+ * - The divider between two filled buttons uses the primary-on-primary edge, so a split primary button reads as one control with two parts
6
+ */
7
+ import * as React from "react"
8
+ import { cva, type VariantProps } from "class-variance-authority"
9
+ import { cn } from "../lib/cn"
10
+ import { Slot } from "radix-ui"
11
+
12
+ import { Separator } from "./separator"
13
+
14
+ const buttonGroupVariants = cva(
15
+ "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-sm [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
16
+ {
17
+ variants: {
18
+ orientation: {
19
+ horizontal:
20
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
21
+ vertical:
22
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ orientation: "horizontal",
27
+ },
28
+ }
29
+ )
30
+
31
+ function ButtonGroup({
32
+ className,
33
+ orientation,
34
+ ...props
35
+ }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
36
+ return (
37
+ <div
38
+ role="group"
39
+ data-slot="button-group"
40
+ data-orientation={orientation}
41
+ className={cn(buttonGroupVariants({ orientation }), className)}
42
+ {...props}
43
+ />
44
+ )
45
+ }
46
+
47
+ function ButtonGroupText({
48
+ className,
49
+ asChild = false,
50
+ ...props
51
+ }: React.ComponentProps<"div"> & {
52
+ asChild?: boolean
53
+ }) {
54
+ const Comp = asChild ? Slot.Root : "div"
55
+
56
+ return (
57
+ <Comp
58
+ className={cn(
59
+ "flex items-center gap-2 rounded-sm border border-line bg-surface-2 px-3 text-base font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-3.5",
60
+ className
61
+ )}
62
+ {...props}
63
+ />
64
+ )
65
+ }
66
+
67
+ function ButtonGroupSeparator({
68
+ className,
69
+ orientation = "vertical",
70
+ ...props
71
+ }: React.ComponentProps<typeof Separator>) {
72
+ return (
73
+ <Separator
74
+ data-slot="button-group-separator"
75
+ orientation={orientation}
76
+ className={cn(
77
+ "relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto in-data-[variant=default]:bg-primary-foreground/30",
78
+ className
79
+ )}
80
+ {...props}
81
+ />
82
+ )
83
+ }
84
+
85
+ export {
86
+ ButtonGroup,
87
+ ButtonGroupSeparator,
88
+ ButtonGroupText,
89
+ buttonGroupVariants,
90
+ }