@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,58 @@
1
+ /**
2
+ * TwoUX composition: SplitButton, built on the generated shadcn ButtonGroup, Button and DropdownMenu.
3
+ * The main part runs the most common action with its label ("Import data"); the chevron part opens the related
4
+ * variants ("Import all", "Import selection") and has its own name ("More import options").
5
+ */
6
+ import { ChevronDown } from "lucide-react"
7
+ import * as React from "react"
8
+ import { cn } from "../lib/cn"
9
+ import { Button } from "./button"
10
+ import { ButtonGroup, ButtonGroupSeparator } from "./button-group"
11
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "./dropdown-menu"
12
+
13
+ export function SplitButton({
14
+ children,
15
+ onClick,
16
+ menuLabel,
17
+ menu,
18
+ variant = "outline",
19
+ size = "sm",
20
+ disabled,
21
+ loading,
22
+ shortcut,
23
+ align = "end",
24
+ className,
25
+ }: {
26
+ /** The main action's label, with an optional leading icon. */
27
+ children: React.ReactNode
28
+ onClick: () => void
29
+ /** Names the chevron part, e.g. "More import options". */
30
+ menuLabel: string
31
+ /** DropdownMenuItem children: the variants of the main action. Repeat the main action first so the menu is complete. */
32
+ menu: React.ReactNode
33
+ variant?: "default" | "outline" | "secondary"
34
+ size?: "xs" | "sm" | "default"
35
+ disabled?: boolean
36
+ loading?: boolean
37
+ shortcut?: string
38
+ align?: "start" | "end"
39
+ className?: string
40
+ }) {
41
+ const icon = size === "xs" ? "icon-xs" : size === "sm" ? "icon-sm" : "icon"
42
+ return (
43
+ <ButtonGroup data-slot="split-button" data-variant={variant} className={className}>
44
+ <Button variant={variant} size={size} onClick={onClick} disabled={disabled} loading={loading} shortcut={shortcut}>
45
+ {children}
46
+ </Button>
47
+ {variant !== "outline" && <ButtonGroupSeparator />}
48
+ <DropdownMenu>
49
+ <DropdownMenuTrigger asChild>
50
+ <Button variant={variant} size={icon} aria-label={menuLabel} disabled={disabled} className={cn(size === "xs" ? "w-5" : "w-6")}>
51
+ <ChevronDown />
52
+ </Button>
53
+ </DropdownMenuTrigger>
54
+ <DropdownMenuContent align={align} className="min-w-48">{menu}</DropdownMenuContent>
55
+ </DropdownMenu>
56
+ </ButtonGroup>
57
+ )
58
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * TwoUX composition: a vertical list of numbered steps joined by a line (shadcn has no stepper).
3
+ * Done steps show a check, the current step is indigo and marked aria-current="step",
4
+ * later steps are faded. With onStepSelect, done steps become buttons to go back to.
5
+ */
6
+ import { Check } from "lucide-react"
7
+ import { cn } from "../lib/cn"
8
+ import { Swap } from "./motion"
9
+
10
+ type StepItem = { title: string; description?: string }
11
+
12
+ export function Stepper({
13
+ steps,
14
+ current,
15
+ onStepSelect,
16
+ className,
17
+ "aria-label": label = "Progress",
18
+ }: {
19
+ steps: StepItem[]
20
+ /** Index of the current step. Steps before it are done. */
21
+ current: number
22
+ /** Lets people go back to a done step. Steps ahead stay locked. */
23
+ onStepSelect?: (index: number) => void
24
+ className?: string
25
+ "aria-label"?: string
26
+ }) {
27
+ return (
28
+ <ol aria-label={label} data-slot="stepper" className={cn("grid", className)}>
29
+ {steps.map((step, i) => {
30
+ const state = i < current ? "done" : i === current ? "current" : "upcoming"
31
+ const last = i === steps.length - 1
32
+ const marker = (
33
+ <span
34
+ data-slot="stepper-marker"
35
+ className={cn(
36
+ "relative z-10 grid size-5 shrink-0 place-items-center rounded-full border text-sm font-semibold tabular transition-[color,background-color,border-color] duration-(--duration-slow) ease-(--ease-standard)",
37
+ state === "done" && "border-primary bg-primary text-primary-foreground",
38
+ state === "current" && "border-primary bg-primary-soft text-primary-ink",
39
+ state === "upcoming" && "border-line-strong bg-surface text-ink-3"
40
+ )}
41
+ >
42
+ <Swap kind="icon" swapKey={state === "done" ? "done" : "number"}>{state === "done" ? <Check className="size-3" strokeWidth={3} aria-hidden="true" /> : i + 1}</Swap>
43
+ </span>
44
+ )
45
+ const text = (
46
+ <span className="grid gap-0.5 pb-5 text-left">
47
+ <span className={cn("text-base font-medium transition-colors duration-(--duration-slow) ease-(--ease-standard)", state === "upcoming" ? "text-ink-3" : "text-ink")}>
48
+ {step.title}
49
+ <span className="sr-only">{state === "done" ? " (done)" : state === "current" ? " (current step)" : ""}</span>
50
+ </span>
51
+ {step.description && <span className={cn("text-sm transition-colors duration-(--duration-slow) ease-(--ease-standard)", state === "upcoming" ? "text-ink-3" : "text-ink-2")}>{step.description}</span>}
52
+ </span>
53
+ )
54
+ const canSelect = state === "done" && !!onStepSelect
55
+ return (
56
+ <li key={step.title} data-state={state} aria-current={state === "current" ? "step" : undefined} className="group/step relative grid grid-cols-[20px_1fr] gap-x-3 has-[button:hover]:[&_[data-slot=stepper-marker]]:bg-primary-hover">
57
+ {!last && (
58
+ // The line fills downwards when the step is done, instead of switching colour.
59
+ <span aria-hidden="true" className="absolute top-5 bottom-0 left-[9.5px] w-px overflow-hidden bg-line-strong">
60
+ <span className={cn("block size-full origin-top bg-primary transition-transform duration-(--duration-slow) ease-(--ease-standard)", i < current ? "scale-y-100" : "scale-y-0")} />
61
+ </span>
62
+ )}
63
+ {/* Same elements in every state, so colour and icon changes can animate; a done step gets a button laid over the row. */}
64
+ {marker}
65
+ {text}
66
+ {canSelect && (
67
+ <button type="button" onClick={() => onStepSelect(i)} className="peer absolute inset-0 rounded-xs">
68
+ <span className="sr-only">Go back to {step.title}</span>
69
+ </button>
70
+ )}
71
+ </li>
72
+ )
73
+ })}
74
+ </ol>
75
+ )
76
+ }
77
+
78
+ export type { StepItem }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "switch" by scripts/tailor-shadcn.mjs. Do not edit by hand.
3
+ * TwoUX changes:
4
+ * - Thumb glides with TwoUX's ease-out
5
+ * - Off track at 3.59:1 so the switch is visible when off (WCAG 1.4.11)
6
+ * - Invisible hit area: 28px around the 16px track (WCAG 2.5.8)
7
+ * - Sized to its label: a 16×28 track by default (14×24 small), no taller than the 15px text beside it, with 2px of track around the thumb
8
+ * - Thumb travels the full track at each size
9
+ */
10
+ "use client"
11
+
12
+ import * as React from "react"
13
+ import { cn } from "../lib/cn"
14
+ import { Switch as SwitchPrimitive } from "radix-ui"
15
+
16
+ function Switch({
17
+ className,
18
+ size = "default",
19
+ ...props
20
+ }: React.ComponentProps<typeof SwitchPrimitive.Root> & {
21
+ size?: "sm" | "default"
22
+ }) {
23
+ return (
24
+ <SwitchPrimitive.Root
25
+ data-slot="switch"
26
+ data-size={size}
27
+ className={cn(
28
+ "peer group/switch relative inline-flex after:absolute after:-inset-1.5 after:rounded-full shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 px-px data-[size=default]:h-4 data-[size=default]:w-7 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-control",
29
+ className
30
+ )}
31
+ {...props}
32
+ >
33
+ <SwitchPrimitive.Thumb
34
+ data-slot="switch-thumb"
35
+ className={cn(
36
+ "pointer-events-none block rounded-full bg-background ring-0 transition-transform duration-(--duration-base) ease-(--ease-out) group-data-[size=default]/switch:size-3 group-data-[size=sm]/switch:size-2.5 group-data-[size=default]/switch:data-[state=checked]:translate-x-3 group-data-[size=sm]/switch:data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0"
37
+ )}
38
+ />
39
+ </SwitchPrimitive.Root>
40
+ )
41
+ }
42
+
43
+ export { Switch }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "table" by scripts/tailor-shadcn.mjs. Do not edit by hand.
3
+ * TwoUX changes:
4
+ * - Adds containerClassName, so a table can scroll inside a fixed height and keep its header sticky
5
+ * - Adds size="dense": 28px rows and header with 8px padding, the same 13px text, for spreadsheet-like data (inventories, ledgers, imports, mapping). Not a density setting
6
+ * - Sticky header on the quiet surface, with the light divider
7
+ * - Rows: light divider, hover fill, indigo tint when selected, an indigo edge on the keyboard-focused row
8
+ * - Rows and header use the row tokens (36px, or 28px dense), 12px side padding and 2px top and bottom, so a 28px control in a cell keeps the row at 36px; header labels 12px medium grey, in sentence case
9
+ * - Frozen columns: data-pinned on a head and its cells keeps them in view when the table scrolls sideways, at left var(--pin); data-pinned="last" draws the edge
10
+ */
11
+ "use client"
12
+
13
+ import * as React from "react"
14
+ import { cn } from "../lib/cn"
15
+
16
+ function Table({ className, containerClassName, size = "default", ...props }: React.ComponentProps<"table"> & { containerClassName?: string; size?: "default" | "dense" }) {
17
+ return (
18
+ <div
19
+ data-slot="table-container"
20
+ className={cn("relative w-full overflow-x-auto", containerClassName)}
21
+ >
22
+ <table
23
+ data-slot="table"
24
+ data-size={size}
25
+ className={cn("group/table w-full caption-bottom text-base", className)}
26
+ {...props}
27
+ />
28
+ </div>
29
+ )
30
+ }
31
+
32
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
33
+ return (
34
+ <thead
35
+ data-slot="table-header"
36
+ className={cn("sticky top-0 z-10 bg-surface-2 [&_tr]:border-b [&_tr]:border-line", className)}
37
+ {...props}
38
+ />
39
+ )
40
+ }
41
+
42
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
43
+ return (
44
+ <tbody
45
+ data-slot="table-body"
46
+ className={cn("[&_tr:last-child]:border-0", className)}
47
+ {...props}
48
+ />
49
+ )
50
+ }
51
+
52
+ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
53
+ return (
54
+ <tfoot
55
+ data-slot="table-footer"
56
+ className={cn(
57
+ "border-t border-line bg-surface-2 font-medium [&>tr]:last:border-b-0",
58
+ className
59
+ )}
60
+ {...props}
61
+ />
62
+ )
63
+ }
64
+
65
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
66
+ return (
67
+ <tr
68
+ data-slot="table-row"
69
+ className={cn(
70
+ "border-b border-line transition-colors duration-(--duration-fast) ease-(--ease-out) outline-none hover:bg-hover has-aria-expanded:bg-hover data-[state=selected]:bg-primary-soft data-[focused=true]:shadow-[inset_2px_0_0_var(--color-primary)]",
71
+ className
72
+ )}
73
+ {...props}
74
+ />
75
+ )
76
+ }
77
+
78
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
79
+ return (
80
+ <th
81
+ data-slot="table-head"
82
+ className={cn(
83
+ "h-row px-3 text-left align-middle text-sm font-medium whitespace-nowrap text-ink-2 group-data-[size=dense]/table:h-row-dense group-data-[size=dense]/table:px-2 data-pinned:sticky data-pinned:left-[var(--pin,0px)] data-pinned:z-20 data-pinned:bg-surface-2 data-[pinned=last]:border-r data-[pinned=last]:border-line [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
84
+ className
85
+ )}
86
+ {...props}
87
+ />
88
+ )
89
+ }
90
+
91
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
92
+ return (
93
+ <td
94
+ data-slot="table-cell"
95
+ className={cn(
96
+ "h-row px-3 py-hair align-middle whitespace-nowrap group-data-[size=dense]/table:h-row-dense group-data-[size=dense]/table:px-2 group-data-[size=dense]/table:py-0 data-pinned:sticky data-pinned:left-[var(--pin,0px)] data-pinned:z-1 data-pinned:bg-surface data-[pinned=last]:border-r data-[pinned=last]:border-line in-[tr:hover]:data-pinned:bg-hover in-data-[state=selected]:data-pinned:bg-primary-soft [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
97
+ className
98
+ )}
99
+ {...props}
100
+ />
101
+ )
102
+ }
103
+
104
+ function TableCaption({
105
+ className,
106
+ ...props
107
+ }: React.ComponentProps<"caption">) {
108
+ return (
109
+ <caption
110
+ data-slot="table-caption"
111
+ className={cn("mt-4 text-base text-muted-foreground", className)}
112
+ {...props}
113
+ />
114
+ )
115
+ }
116
+
117
+ export {
118
+ Table,
119
+ TableHeader,
120
+ TableBody,
121
+ TableFooter,
122
+ TableHead,
123
+ TableRow,
124
+ TableCell,
125
+ TableCaption,
126
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "tabs" by scripts/tailor-shadcn.mjs. Do not edit by hand.
3
+ * TwoUX changes:
4
+ * - Inactive tabs use secondary text (9:1), not 60% ink, which is 4.46:1 on the tab rail
5
+ * - TwoUX's default is the underline (line) style for page sections
6
+ * - Line tabs span the width with a divider underneath
7
+ * - Line tabs: no padding, indigo text and underline when active
8
+ * - Adds count after the label; it pops when it changes
9
+ * - One indicator slides to the active tab (underline or pill) instead of jumping; vertical tabs keep per-tab styling
10
+ */
11
+ "use client"
12
+
13
+ import * as React from "react"
14
+ import { cva, type VariantProps } from "class-variance-authority"
15
+ import { cn } from "../lib/cn"
16
+ import { Tabs as TabsPrimitive } from "radix-ui"
17
+ import { Count } from "./motion"
18
+ import { ActiveIndicator } from "../lib/active-indicator"
19
+
20
+ function Tabs({
21
+ className,
22
+ orientation = "horizontal",
23
+ ...props
24
+ }: React.ComponentProps<typeof TabsPrimitive.Root>) {
25
+ return (
26
+ <TabsPrimitive.Root
27
+ data-slot="tabs"
28
+ data-orientation={orientation}
29
+ orientation={orientation}
30
+ className={cn(
31
+ "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
32
+ className
33
+ )}
34
+ {...props}
35
+ />
36
+ )
37
+ }
38
+
39
+ const tabsListVariants = cva(
40
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-md p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
41
+ {
42
+ variants: {
43
+ variant: {
44
+ default: "bg-muted",
45
+ line: "h-9 w-full justify-start gap-5 rounded-none border-b border-line bg-transparent p-0",
46
+ },
47
+ },
48
+ defaultVariants: {
49
+ variant: "line",
50
+ },
51
+ }
52
+ )
53
+
54
+ function TabsList({
55
+ className,
56
+ variant = "line",
57
+ ...props
58
+ }: React.ComponentProps<typeof TabsPrimitive.List> &
59
+ VariantProps<typeof tabsListVariants>) {
60
+ return (
61
+ <TabsPrimitive.List
62
+ data-slot="tabs-list"
63
+ data-variant={variant}
64
+ className={cn(tabsListVariants({ variant }), "relative", className)}
65
+ {...props}
66
+ >
67
+ <ActiveIndicator
68
+ selector="[data-state=active]"
69
+ fit={variant === "line" ? "x" : "box"}
70
+ className={cn(
71
+ "group-data-[orientation=vertical]/tabs:hidden",
72
+ variant === "line" ? "bottom-[-1px] h-0.5 rounded-full bg-primary" : "rounded-sm bg-background shadow-sm"
73
+ )}
74
+ />
75
+ {props.children}
76
+ </TabsPrimitive.List>
77
+ )
78
+ }
79
+
80
+ function TabsTrigger({
81
+ className,
82
+ count,
83
+ children,
84
+ ...props
85
+ }: React.ComponentProps<typeof TabsPrimitive.Trigger> & { count?: number }) {
86
+ return (
87
+ <TabsPrimitive.Trigger
88
+ data-slot="tabs-trigger"
89
+ className={cn(
90
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-sm border border-transparent px-2 py-1 text-base font-medium whitespace-nowrap text-ink-2 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[orientation=vertical]/tabs:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
91
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
92
+ "group-data-[orientation=vertical]/tabs:data-[state=active]:bg-background data-[state=active]:text-foreground",
93
+ "group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:px-0 group-data-[variant=line]/tabs-list:data-[state=active]:text-primary-ink group-data-[variant=line]/tabs-list:after:bottom-[-1px] group-data-[variant=line]/tabs-list:after:bg-primary",
94
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[orientation=vertical]/tabs:group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
95
+ className
96
+ )}
97
+ {...props}
98
+ >
99
+ {children}
100
+ {count !== undefined && <Count value={count} className="text-sm font-normal text-ink-3" />}
101
+ </TabsPrimitive.Trigger>
102
+ )
103
+ }
104
+
105
+ function TabsContent({
106
+ className,
107
+ ...props
108
+ }: React.ComponentProps<typeof TabsPrimitive.Content>) {
109
+ return (
110
+ <TabsPrimitive.Content
111
+ data-slot="tabs-content"
112
+ className={cn("flex-1 outline-none", className)}
113
+ {...props}
114
+ />
115
+ )
116
+ }
117
+
118
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * TagInput: several short values that don't come from a list (email addresses, domains, keywords), typed or pasted.
3
+ * TwoUX composition on the generated shadcn Field. Nothing is dropped silently: duplicates are reported, invalid values
4
+ * stay as marked tags with their reason, and values past the limit stay in the input. Proposal: proposals/tag-input.md.
5
+ */
6
+ "use client"
7
+
8
+ import { AlertCircle, X } from "lucide-react"
9
+ import * as React from "react"
10
+ import { cn } from "../lib/cn"
11
+ import { Field, FieldDescription, FieldLabel } from "./field"
12
+
13
+ type TagInputProps = {
14
+ label: string
15
+ value: string[]
16
+ onValueChange: (value: string[]) => void
17
+ /** The reason a value is not valid, or undefined when it is: "Not an email address." */
18
+ validateValue?: (value: string) => string | undefined
19
+ /** Most values people can add. Shows "3 of 10" under the field. */
20
+ max?: number
21
+ /** The thing entered, used in messages: { one: "address", many: "addresses" }. Defaults to value and values. */
22
+ noun?: { one: string; many: string }
23
+ /** Defaults to "Separate <many> with commas or new lines". */
24
+ hint?: string
25
+ /** An error from the form's own checks, such as "Add at least one address." */
26
+ error?: string
27
+ placeholder?: string
28
+ required?: boolean
29
+ optional?: boolean
30
+ disabled?: boolean
31
+ /** Values stay focusable and readable, without remove or edit. */
32
+ readOnly?: boolean
33
+ id?: string
34
+ /** Each value is submitted as a separate form value. */
35
+ name?: string
36
+ className?: string
37
+ }
38
+
39
+ const SPLIT = /[,;\n\t]+/
40
+ const same = (a: string, b: string) => a.localeCompare(b, "en-GB", { sensitivity: "base" }) === 0
41
+
42
+ /** Several free values in one field: Enter or comma adds, pasted lists are split, each value is checked as it is added. */
43
+ export function TagInput({ label, value, onValueChange, validateValue, max, noun = { one: "value", many: "values" }, hint, error, placeholder, required, optional, disabled, readOnly, id, name, className }: TagInputProps) {
44
+ const auto = React.useId()
45
+ const fid = id ?? auto
46
+ const inputRef = React.useRef<HTMLInputElement>(null)
47
+ const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([])
48
+ const [text, setText] = React.useState("")
49
+ const [notice, setNotice] = React.useState("")
50
+ const [flash, setFlash] = React.useState<string | null>(null)
51
+ const [focusIndex, setFocusIndex] = React.useState<number | null>(null)
52
+
53
+ React.useEffect(() => {
54
+ if (focusIndex === null) return
55
+ ;(chipRefs.current[focusIndex] ?? inputRef.current)?.focus()
56
+ setFocusIndex(null)
57
+ }, [focusIndex, value])
58
+ React.useEffect(() => {
59
+ if (!flash) return
60
+ const t = setTimeout(() => setFlash(null), 600)
61
+ return () => clearTimeout(t)
62
+ }, [flash])
63
+
64
+ const reasons = value.map((v) => validateValue?.(v))
65
+ const invalid = reasons.filter(Boolean).length
66
+ const word = (n: number) => (n === 1 ? noun.one : noun.many)
67
+
68
+ /** Adds values; returns the text that has to stay in the input (values past the limit). */
69
+ const add = (items: string[], pasted: boolean) => {
70
+ const next = [...value]
71
+ let added = 0, bad = 0
72
+ const dups: string[] = []
73
+ const left: string[] = []
74
+ for (const raw of items) {
75
+ const v = raw.trim()
76
+ if (!v) continue
77
+ const dup = next.find((x) => same(x, v))
78
+ if (dup) { dups.push(dup); continue }
79
+ if (max && next.length >= max) { left.push(v); continue }
80
+ next.push(v)
81
+ added++
82
+ if (validateValue?.(v)) bad++
83
+ }
84
+ if (added) onValueChange(next)
85
+ const parts: string[] = []
86
+ if (pasted && added) parts.push(`${added} added${bad ? `, ${bad} not valid` : ""}.`)
87
+ else if (bad) parts.push(`${validateValue?.(next[next.length - 1])}`)
88
+ if (dups.length === 1 && !pasted) parts.push(`${dups[0]} is already added.`)
89
+ else if (dups.length) parts.push(`${dups.length} already added.`)
90
+ if (left.length) parts.push(`You can add up to ${max}. ${left.length} ${word(left.length)} weren't added.`)
91
+ setNotice(parts.join(" "))
92
+ if (dups.length) setFlash(dups[dups.length - 1])
93
+ return left.join(", ")
94
+ }
95
+ const commit = () => {
96
+ if (!text.trim()) return
97
+ setText(add(text.split(SPLIT), false))
98
+ }
99
+ const remove = (i: number, then: "next" | "prev") => {
100
+ const v = value[i]
101
+ onValueChange(value.filter((_, j) => j !== i))
102
+ setNotice(`${v} removed.`)
103
+ // Focus the tag before (Backspace) or the one that moved into this place (Delete); the input when none is left.
104
+ const left = value.length - 1
105
+ setFocusIndex(left === 0 ? -1 : then === "prev" ? Math.max(i - 1, 0) : Math.min(i, left - 1))
106
+ }
107
+ const edit = (i: number) => {
108
+ const v = value[i]
109
+ onValueChange(value.filter((_, j) => j !== i))
110
+ setText(v)
111
+ setNotice("")
112
+ setFocusIndex(-1)
113
+ }
114
+
115
+ const shownError = error ?? (invalid ? `${invalid} ${word(invalid)} ${invalid === 1 ? "isn't" : "aren't"} valid. Fix or remove ${invalid === 1 ? "it" : "them"}.` : undefined)
116
+ const hintText = hint ?? `Separate ${noun.many} with commas or new lines`
117
+ const msgId = `${fid}-msg`
118
+ const helpId = `${fid}-help`
119
+
120
+ return (
121
+ <Field data-invalid={!!shownError || undefined} className={cn("gap-2", className)}>
122
+ <FieldLabel htmlFor={fid} id={`${fid}-label`}>
123
+ {label}
124
+ {required && <span aria-hidden="true" className="text-bad">*</span>}
125
+ {optional && <span className="font-normal text-ink-3">(optional)</span>}
126
+ </FieldLabel>
127
+ <div
128
+ data-slot="tag-input"
129
+ onMouseDown={(e) => {
130
+ if (e.target === e.currentTarget) {
131
+ e.preventDefault()
132
+ inputRef.current?.focus()
133
+ }
134
+ }}
135
+ className={cn(
136
+ "flex min-h-8 w-full min-w-0 cursor-text flex-wrap items-center gap-1 rounded-sm border border-input bg-background px-1 py-1 shadow-xs transition-[color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-(--ease-out) hover:border-input-hover",
137
+ "focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
138
+ shownError && "not-focus-within:border-destructive-edge",
139
+ disabled && "pointer-events-none opacity-50",
140
+ )}
141
+ >
142
+ {value.length > 0 && (
143
+ <ul role="list" aria-labelledby={`${fid}-label`} className="contents">
144
+ {value.map((v, i) => {
145
+ const reason = reasons[i]
146
+ return (
147
+ <li
148
+ key={v}
149
+ className={cn(
150
+ "inline-flex h-5.5 max-w-60 items-center gap-1 rounded-xs border px-control text-sm transition-colors duration-(--duration-base) ease-(--ease-out)",
151
+ reason ? "border-bad-line bg-bad-bg text-ink" : "border-line-strong bg-surface text-ink",
152
+ flash === v && "bg-selected",
153
+ )}
154
+ >
155
+ {reason && <AlertCircle className="size-3 shrink-0 text-bad" aria-hidden="true" />}
156
+ <button
157
+ type="button"
158
+ tabIndex={-1}
159
+ ref={(el) => {
160
+ chipRefs.current[i] = el
161
+ }}
162
+ aria-label={reason ? `${v}, not valid: ${reason}` : v}
163
+ aria-describedby={readOnly ? undefined : helpId}
164
+ title={reason}
165
+ disabled={disabled}
166
+ onDoubleClick={() => !readOnly && edit(i)}
167
+ onKeyDown={(e) => {
168
+ if (e.key === "ArrowLeft") { e.preventDefault(); chipRefs.current[i - 1]?.focus() }
169
+ if (e.key === "ArrowRight") { e.preventDefault(); (chipRefs.current[i + 1] ?? inputRef.current)?.focus() }
170
+ if (e.key === "Home") { e.preventDefault(); chipRefs.current[0]?.focus() }
171
+ if (e.key === "End" || e.key === "Escape") { e.preventDefault(); inputRef.current?.focus() }
172
+ if (readOnly) return
173
+ if (e.key === "Backspace") { e.preventDefault(); remove(i, "prev") }
174
+ if (e.key === "Delete") { e.preventDefault(); remove(i, "next") }
175
+ if (e.key === "Enter") { e.preventDefault(); edit(i) }
176
+ }}
177
+ className="min-w-0 truncate rounded-xs outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring"
178
+ >
179
+ {v}
180
+ </button>
181
+ {!readOnly && (
182
+ <button
183
+ type="button"
184
+ tabIndex={-1}
185
+ aria-label={`Remove ${v}`}
186
+ disabled={disabled}
187
+ onClick={() => remove(i, "next")}
188
+ className="relative -mr-0.5 grid size-4 shrink-0 place-items-center rounded-xs text-ink-3 after:absolute after:-inset-1 hover:bg-hover hover:text-ink"
189
+ >
190
+ <X className="size-3" aria-hidden="true" />
191
+ </button>
192
+ )}
193
+ </li>
194
+ )
195
+ })}
196
+ </ul>
197
+ )}
198
+ <input
199
+ ref={inputRef}
200
+ id={fid}
201
+ value={text}
202
+ readOnly={readOnly}
203
+ disabled={disabled}
204
+ placeholder={value.length ? undefined : placeholder}
205
+ autoComplete="off"
206
+ aria-invalid={shownError ? true : undefined}
207
+ aria-describedby={msgId}
208
+ aria-required={required || undefined}
209
+ onChange={(e) => {
210
+ const t = e.target.value
211
+ if (/[,;]/.test(t)) setText(add(t.split(SPLIT), false))
212
+ else setText(t)
213
+ }}
214
+ onPaste={(e) => {
215
+ const pasted = e.clipboardData.getData("text")
216
+ if (!SPLIT.test(pasted)) return
217
+ e.preventDefault()
218
+ setText(add((text + pasted).split(SPLIT), true))
219
+ }}
220
+ onBlur={commit}
221
+ onKeyDown={(e) => {
222
+ const atStart = e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === 0
223
+ if (e.key === "Enter" && text.trim()) { e.preventDefault(); commit() }
224
+ if (e.key === "Tab" && !e.shiftKey && text.trim()) commit()
225
+ if ((e.key === "Backspace" || e.key === "ArrowLeft") && atStart && value.length) {
226
+ e.preventDefault()
227
+ chipRefs.current[value.length - 1]?.focus()
228
+ }
229
+ }}
230
+ className="h-5.5 min-w-24 flex-1 bg-transparent px-control text-base outline-none placeholder:text-muted-foreground"
231
+ />
232
+ </div>
233
+ <div className="flex items-start gap-2">
234
+ <div className="min-w-0 flex-1">
235
+ {shownError ? (
236
+ // One message that wraps as text. Each invalid tag carries its own reason, and the live notice
237
+ // still tells screen readers what the last paste or entry did.
238
+ <p id={msgId} className="flex items-start gap-control text-sm text-bad">
239
+ <AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
240
+ <span className="min-w-0">{shownError}</span>
241
+ </p>
242
+ ) : (
243
+ <FieldDescription id={msgId}>{notice || hintText}</FieldDescription>
244
+ )}
245
+ </div>
246
+ {max && <span className="tabular shrink-0 text-sm text-ink-3">{value.length} of {max}</span>}
247
+ </div>
248
+ <span id={helpId} hidden>Enter to edit, Delete to remove</span>
249
+ <span className="sr-only" role="status" aria-live="polite">{notice}</span>
250
+ {name && value.map((v) => <input key={v} type="hidden" name={name} value={v} />)}
251
+ </Field>
252
+ )
253
+ }