@twoux/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/LICENSE.md +61 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/NOTICE.md +64 -0
  4. package/README.md +211 -2
  5. package/SHADCN-DIFF.md +228 -0
  6. package/agent/AGENTS.md +358 -0
  7. package/agent/docs-md/accessibility.md +49 -0
  8. package/agent/docs-md/action-catalogue.md +81 -0
  9. package/agent/docs-md/actions.md +74 -0
  10. package/agent/docs-md/agent-activity-log.md +70 -0
  11. package/agent/docs-md/agent-permissions.md +82 -0
  12. package/agent/docs-md/agent-run.md +90 -0
  13. package/agent/docs-md/agents-on-behalf.md +74 -0
  14. package/agent/docs-md/ai-column.md +85 -0
  15. package/agent/docs-md/ai-origin-label.md +70 -0
  16. package/agent/docs-md/announcing-features.md +73 -0
  17. package/agent/docs-md/answer-actions.md +81 -0
  18. package/agent/docs-md/app-shell.md +114 -0
  19. package/agent/docs-md/artifact-card.md +89 -0
  20. package/agent/docs-md/ask-on-screen.md +72 -0
  21. package/agent/docs-md/assistant-page.md +100 -0
  22. package/agent/docs-md/assistant-panel.md +142 -0
  23. package/agent/docs-md/avatar.md +82 -0
  24. package/agent/docs-md/banner.md +106 -0
  25. package/agent/docs-md/board.md +163 -0
  26. package/agent/docs-md/breadcrumb.md +89 -0
  27. package/agent/docs-md/breakdown-table.md +97 -0
  28. package/agent/docs-md/bulk-action-bar.md +112 -0
  29. package/agent/docs-md/button.md +293 -0
  30. package/agent/docs-md/card.md +115 -0
  31. package/agent/docs-md/change-list.md +99 -0
  32. package/agent/docs-md/change-request.md +166 -0
  33. package/agent/docs-md/chart-area.md +87 -0
  34. package/agent/docs-md/chart-bar.md +100 -0
  35. package/agent/docs-md/chart-box-plot.md +88 -0
  36. package/agent/docs-md/chart-bullet.md +89 -0
  37. package/agent/docs-md/chart-candlestick.md +112 -0
  38. package/agent/docs-md/chart-card-line.md +120 -0
  39. package/agent/docs-md/chart-donut.md +90 -0
  40. package/agent/docs-md/chart-drill-down.md +72 -0
  41. package/agent/docs-md/chart-funnel.md +71 -0
  42. package/agent/docs-md/chart-heatmap.md +76 -0
  43. package/agent/docs-md/chart-histogram.md +81 -0
  44. package/agent/docs-md/chart-map.md +165 -0
  45. package/agent/docs-md/chart-mixed.md +88 -0
  46. package/agent/docs-md/chart-price.md +92 -0
  47. package/agent/docs-md/chart-radial-bars.md +109 -0
  48. package/agent/docs-md/chart-radial-lines.md +111 -0
  49. package/agent/docs-md/chart-sankey.md +104 -0
  50. package/agent/docs-md/chart-scatter.md +87 -0
  51. package/agent/docs-md/chart-share-bar.md +89 -0
  52. package/agent/docs-md/chart-treemap.md +98 -0
  53. package/agent/docs-md/chart-waterfall.md +93 -0
  54. package/agent/docs-md/checkbox.md +131 -0
  55. package/agent/docs-md/choosing-selection-control.md +71 -0
  56. package/agent/docs-md/citations-sources.md +79 -0
  57. package/agent/docs-md/citing-sources.md +63 -0
  58. package/agent/docs-md/clarify-before-acting.md +69 -0
  59. package/agent/docs-md/clarifying-question.md +92 -0
  60. package/agent/docs-md/code-block.md +174 -0
  61. package/agent/docs-md/colours.md +87 -0
  62. package/agent/docs-md/columns-menu.md +140 -0
  63. package/agent/docs-md/combobox.md +161 -0
  64. package/agent/docs-md/command-palette.md +126 -0
  65. package/agent/docs-md/confidence-note.md +87 -0
  66. package/agent/docs-md/configurable-dashboard.md +76 -0
  67. package/agent/docs-md/confirmation-dialog.md +93 -0
  68. package/agent/docs-md/connected-tools.md +80 -0
  69. package/agent/docs-md/connector-picker.md +127 -0
  70. package/agent/docs-md/consistency.md +53 -0
  71. package/agent/docs-md/content-density.md +61 -0
  72. package/agent/docs-md/conversation-history.md +78 -0
  73. package/agent/docs-md/create-record.md +80 -0
  74. package/agent/docs-md/csv-import.md +88 -0
  75. package/agent/docs-md/dashboard-items.md +78 -0
  76. package/agent/docs-md/data-export.md +61 -0
  77. package/agent/docs-md/data-grid.md +137 -0
  78. package/agent/docs-md/data-vis-colours.md +76 -0
  79. package/agent/docs-md/data-visualisation.md +76 -0
  80. package/agent/docs-md/date-picker.md +178 -0
  81. package/agent/docs-md/delete-record.md +75 -0
  82. package/agent/docs-md/dense-table.md +495 -0
  83. package/agent/docs-md/design-tokens.md +53 -0
  84. package/agent/docs-md/disabled-read-only.md +75 -0
  85. package/agent/docs-md/drag-and-drop.md +74 -0
  86. package/agent/docs-md/dropdown-menu.md +137 -0
  87. package/agent/docs-md/edit-record.md +73 -0
  88. package/agent/docs-md/empty-loading-error.md +78 -0
  89. package/agent/docs-md/empty-state.md +128 -0
  90. package/agent/docs-md/error-messages.md +75 -0
  91. package/agent/docs-md/error-state.md +123 -0
  92. package/agent/docs-md/export-menu.md +86 -0
  93. package/agent/docs-md/feedback-prompt.md +88 -0
  94. package/agent/docs-md/field-mapping.md +78 -0
  95. package/agent/docs-md/file-drop.md +110 -0
  96. package/agent/docs-md/filter-bar.md +138 -0
  97. package/agent/docs-md/filter-panel.md +103 -0
  98. package/agent/docs-md/filtering-search.md +79 -0
  99. package/agent/docs-md/follow-ups-starters.md +79 -0
  100. package/agent/docs-md/form-validation.md +95 -0
  101. package/agent/docs-md/generated-columns.md +65 -0
  102. package/agent/docs-md/generated-drafts-files.md +71 -0
  103. package/agent/docs-md/grouped-list.md +89 -0
  104. package/agent/docs-md/guide-cue.md +170 -0
  105. package/agent/docs-md/help-panel.md +103 -0
  106. package/agent/docs-md/help-system.md +74 -0
  107. package/agent/docs-md/iconography.md +49 -0
  108. package/agent/docs-md/inbox.md +108 -0
  109. package/agent/docs-md/inline-editing-validation.md +80 -0
  110. package/agent/docs-md/inline-input-in-answer.md +70 -0
  111. package/agent/docs-md/items-palette.md +116 -0
  112. package/agent/docs-md/keyboard-model.md +47 -0
  113. package/agent/docs-md/keyboard-navigation.md +68 -0
  114. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  115. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  116. package/agent/docs-md/labelling-ai-output.md +66 -0
  117. package/agent/docs-md/layout.md +47 -0
  118. package/agent/docs-md/list-table.md +254 -0
  119. package/agent/docs-md/loading-skeleton.md +91 -0
  120. package/agent/docs-md/managing-agents.md +71 -0
  121. package/agent/docs-md/message-composer.md +105 -0
  122. package/agent/docs-md/migrating.md +49 -0
  123. package/agent/docs-md/motion.md +93 -0
  124. package/agent/docs-md/navigation.md +76 -0
  125. package/agent/docs-md/notification-channels.md +65 -0
  126. package/agent/docs-md/notifications-decision.md +100 -0
  127. package/agent/docs-md/onboarding-step.md +114 -0
  128. package/agent/docs-md/order-book.md +108 -0
  129. package/agent/docs-md/page-header.md +96 -0
  130. package/agent/docs-md/pagination.md +88 -0
  131. package/agent/docs-md/pane-layout.md +105 -0
  132. package/agent/docs-md/period-comparison.md +66 -0
  133. package/agent/docs-md/plan-card.md +120 -0
  134. package/agent/docs-md/popover-rules.md +88 -0
  135. package/agent/docs-md/prompt-editor.md +90 -0
  136. package/agent/docs-md/propose-apply.md +65 -0
  137. package/agent/docs-md/provider-logo.md +97 -0
  138. package/agent/docs-md/radio.md +83 -0
  139. package/agent/docs-md/ranked-list.md +102 -0
  140. package/agent/docs-md/reasoning-progress.md +72 -0
  141. package/agent/docs-md/record-detail.md +71 -0
  142. package/agent/docs-md/regenerating-answers.md +70 -0
  143. package/agent/docs-md/run-status-bar.md +94 -0
  144. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  145. package/agent/docs-md/saved-views.md +149 -0
  146. package/agent/docs-md/saving-views.md +68 -0
  147. package/agent/docs-md/scheduler.md +258 -0
  148. package/agent/docs-md/secondary-panels.md +72 -0
  149. package/agent/docs-md/segmented-control.md +88 -0
  150. package/agent/docs-md/selection-bulk-actions.md +70 -0
  151. package/agent/docs-md/separator.md +75 -0
  152. package/agent/docs-md/setup-checklist.md +92 -0
  153. package/agent/docs-md/shape-elevation.md +55 -0
  154. package/agent/docs-md/sheet-grid.md +211 -0
  155. package/agent/docs-md/side-panel.md +134 -0
  156. package/agent/docs-md/sign-in.md +114 -0
  157. package/agent/docs-md/spacing.md +55 -0
  158. package/agent/docs-md/split-button.md +100 -0
  159. package/agent/docs-md/stat-compare.md +82 -0
  160. package/agent/docs-md/static-dashboard.md +75 -0
  161. package/agent/docs-md/status-badge.md +140 -0
  162. package/agent/docs-md/step-card.md +75 -0
  163. package/agent/docs-md/stepper.md +92 -0
  164. package/agent/docs-md/suggested-changes.md +91 -0
  165. package/agent/docs-md/switch.md +78 -0
  166. package/agent/docs-md/table-settings.md +81 -0
  167. package/agent/docs-md/tabs.md +85 -0
  168. package/agent/docs-md/tag-input.md +108 -0
  169. package/agent/docs-md/tag.md +79 -0
  170. package/agent/docs-md/task-builder.md +87 -0
  171. package/agent/docs-md/team-instructions.md +72 -0
  172. package/agent/docs-md/templates-gallery.md +85 -0
  173. package/agent/docs-md/text-input.md +160 -0
  174. package/agent/docs-md/thinking-steps.md +89 -0
  175. package/agent/docs-md/timeline.md +189 -0
  176. package/agent/docs-md/timestamp.md +84 -0
  177. package/agent/docs-md/timestamps.md +70 -0
  178. package/agent/docs-md/toast.md +117 -0
  179. package/agent/docs-md/tooltip.md +90 -0
  180. package/agent/docs-md/typography.md +62 -0
  181. package/agent/docs-md/undo-destructive.md +66 -0
  182. package/agent/docs-md/unsaved-changes.md +71 -0
  183. package/agent/docs-md/usage-meter.md +80 -0
  184. package/agent/docs-md/user-feedback.md +71 -0
  185. package/agent/docs-md/voice-and-tone.md +60 -0
  186. package/agent/docs-md/why-choice.md +93 -0
  187. package/agent/docs-md/workspace-onboarding.md +74 -0
  188. package/agent/docs-md/writing.md +48 -0
  189. package/agent/llms-full.txt +18369 -0
  190. package/agent/llms.txt +198 -0
  191. package/agent/skills/twoux-audit/SKILL.md +49 -0
  192. package/agent/skills/twoux-build/SKILL.md +39 -0
  193. package/agent/skills/twoux-design/SKILL.md +54 -0
  194. package/agent/skills/twoux-write/SKILL.md +32 -0
  195. package/bin/check.mjs +123 -0
  196. package/bin/docs.mjs +24 -0
  197. package/bin/guard.mjs +27 -0
  198. package/bin/init.mjs +113 -0
  199. package/bin/knowledge.mjs +87 -0
  200. package/bin/mcp.mjs +116 -0
  201. package/bin/theme.mjs +168 -0
  202. package/bin/twoux.mjs +25 -0
  203. package/package.json +80 -3
  204. package/registry/jobs.json +1028 -0
  205. package/registry/palette.json +770 -0
  206. package/registry/rules.json +913 -0
  207. package/src/components/agent-review.tsx +364 -0
  208. package/src/components/ai-basics.tsx +212 -0
  209. package/src/components/ai-work.tsx +304 -0
  210. package/src/components/ai.tsx +227 -0
  211. package/src/components/alert-dialog.tsx +203 -0
  212. package/src/components/alert.tsx +86 -0
  213. package/src/components/avatar.tsx +114 -0
  214. package/src/components/badge.tsx +59 -0
  215. package/src/components/board.tsx +397 -0
  216. package/src/components/breadcrumb.tsx +114 -0
  217. package/src/components/button-group.tsx +90 -0
  218. package/src/components/button.tsx +109 -0
  219. package/src/components/calendar.tsx +247 -0
  220. package/src/components/card.tsx +102 -0
  221. package/src/components/chart-map.tsx +336 -0
  222. package/src/components/charts-more.tsx +1331 -0
  223. package/src/components/charts-radial.tsx +328 -0
  224. package/src/components/charts.tsx +390 -0
  225. package/src/components/checkbox.tsx +40 -0
  226. package/src/components/code.tsx +139 -0
  227. package/src/components/collapsible.tsx +37 -0
  228. package/src/components/combobox.tsx +339 -0
  229. package/src/components/command-extras.tsx +11 -0
  230. package/src/components/command.tsx +196 -0
  231. package/src/components/data-grid.tsx +213 -0
  232. package/src/components/data-table.tsx +334 -0
  233. package/src/components/data-views.tsx +740 -0
  234. package/src/components/date-picker.tsx +686 -0
  235. package/src/components/dense-table.tsx +207 -0
  236. package/src/components/dialog.tsx +162 -0
  237. package/src/components/dropdown-menu.tsx +264 -0
  238. package/src/components/empty.tsx +114 -0
  239. package/src/components/feedback-prompt.tsx +80 -0
  240. package/src/components/field.tsx +254 -0
  241. package/src/components/fields.tsx +112 -0
  242. package/src/components/flow.tsx +840 -0
  243. package/src/components/guide-cue.tsx +273 -0
  244. package/src/components/inbox.tsx +113 -0
  245. package/src/components/input-group.tsx +176 -0
  246. package/src/components/input.tsx +26 -0
  247. package/src/components/kbd.tsx +34 -0
  248. package/src/components/label.tsx +27 -0
  249. package/src/components/labels.tsx +176 -0
  250. package/src/components/motion.tsx +136 -0
  251. package/src/components/onboarding.tsx +454 -0
  252. package/src/components/order-book.tsx +185 -0
  253. package/src/components/pane-layout.tsx +417 -0
  254. package/src/components/popover.tsx +96 -0
  255. package/src/components/provider-logos.data.ts +49 -0
  256. package/src/components/radio-group.tsx +52 -0
  257. package/src/components/scheduler.tsx +997 -0
  258. package/src/components/segmented-control.tsx +28 -0
  259. package/src/components/select.tsx +195 -0
  260. package/src/components/separator.tsx +31 -0
  261. package/src/components/sheet.tsx +148 -0
  262. package/src/components/shell.tsx +426 -0
  263. package/src/components/shortcut-sheet.tsx +83 -0
  264. package/src/components/skeleton.tsx +18 -0
  265. package/src/components/sonner.tsx +107 -0
  266. package/src/components/spinner.tsx +13 -0
  267. package/src/components/split-button.tsx +58 -0
  268. package/src/components/stepper.tsx +78 -0
  269. package/src/components/switch.tsx +43 -0
  270. package/src/components/table.tsx +126 -0
  271. package/src/components/tabs.tsx +118 -0
  272. package/src/components/tag-input.tsx +253 -0
  273. package/src/components/textarea.tsx +21 -0
  274. package/src/components/theme.tsx +80 -0
  275. package/src/components/timeline.tsx +408 -0
  276. package/src/components/toggle-group.tsx +87 -0
  277. package/src/components/toggle.tsx +54 -0
  278. package/src/components/tooltip.tsx +65 -0
  279. package/src/components/workspace-actions.tsx +294 -0
  280. package/src/components/workspace-import.tsx +465 -0
  281. package/src/components/workspace.tsx +1884 -0
  282. package/src/index.ts +80 -0
  283. package/src/lib/active-indicator.tsx +53 -0
  284. package/src/lib/chart-kit.tsx +72 -0
  285. package/src/lib/cn.ts +6 -0
  286. package/src/lib/shortcuts.tsx +61 -0
  287. package/src/lib/workspace.tsx +10 -0
  288. package/src/shadcn/alert-dialog.tsx +196 -0
  289. package/src/shadcn/alert.tsx +65 -0
  290. package/src/shadcn/avatar.tsx +108 -0
  291. package/src/shadcn/badge.tsx +47 -0
  292. package/src/shadcn/breadcrumb.tsx +108 -0
  293. package/src/shadcn/button-group.tsx +83 -0
  294. package/src/shadcn/button.tsx +63 -0
  295. package/src/shadcn/calendar.tsx +220 -0
  296. package/src/shadcn/card.tsx +91 -0
  297. package/src/shadcn/checkbox.tsx +31 -0
  298. package/src/shadcn/collapsible.tsx +33 -0
  299. package/src/shadcn/command.tsx +184 -0
  300. package/src/shadcn/dialog.tsx +158 -0
  301. package/src/shadcn/dropdown-menu.tsx +256 -0
  302. package/src/shadcn/empty.tsx +103 -0
  303. package/src/shadcn/field.tsx +248 -0
  304. package/src/shadcn/input-group.tsx +170 -0
  305. package/src/shadcn/input.tsx +20 -0
  306. package/src/shadcn/kbd.tsx +28 -0
  307. package/src/shadcn/label.tsx +23 -0
  308. package/src/shadcn/popover.tsx +88 -0
  309. package/src/shadcn/radio-group.tsx +44 -0
  310. package/src/shadcn/select.tsx +189 -0
  311. package/src/shadcn/separator.tsx +27 -0
  312. package/src/shadcn/sheet.tsx +142 -0
  313. package/src/shadcn/skeleton.tsx +13 -0
  314. package/src/shadcn/sonner.tsx +40 -0
  315. package/src/shadcn/switch.tsx +34 -0
  316. package/src/shadcn/table.tsx +115 -0
  317. package/src/shadcn/tabs.tsx +90 -0
  318. package/src/shadcn/textarea.tsx +17 -0
  319. package/src/shadcn/toggle-group.tsx +83 -0
  320. package/src/shadcn/toggle.tsx +46 -0
  321. package/src/shadcn/tooltip.tsx +56 -0
  322. package/src/styles/palette.css +127 -0
  323. package/src/styles/theme.css +35 -0
  324. package/src/styles/twoux.css +236 -0
  325. package/theme.json +14 -0
@@ -0,0 +1,740 @@
1
+ /**
2
+ * TwoUX compositions for working with collections, built only from generated shadcn parts.
3
+ * FilterButton + FilterRow: the filter UX to use (button in the toolbar, applied filters on their own row). Built from
4
+ * FilterBar, FilterPill, FilterMenu, FilterSummary: filters as segmented chips that read as sentences; values apply on tick.
5
+ * ExportMenu: options, then downloads, then copies, in one menu.
6
+ * GroupedList, ListGroup: records under collapsible, counted group headers.
7
+ * SavedViews: named filter + sort + column sets as tabs, with unsaved changes, save and reset.
8
+ */
9
+ import { Check, ChevronRight, Download, EllipsisVertical, ListFilter, Pencil, Plus, Save, Star, Trash2, X } from "lucide-react"
10
+ import * as React from "react"
11
+ import { cn } from "../lib/cn"
12
+ import { useShortcut } from "../lib/shortcuts"
13
+ import { Badge } from "./badge"
14
+ import { Button } from "./button"
15
+ import { Checkbox } from "./checkbox"
16
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible"
17
+ import { DateFilterPill, DateRangeChooser, type DatePreset, dateFilterFromValues, dateFilterToValues, describeDateFilter } from "./date-picker"
18
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./command"
19
+ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./dialog"
20
+ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./dropdown-menu"
21
+ import { Input } from "./input"
22
+ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "./popover"
23
+ import { Tabs, TabsList, TabsTrigger } from "./tabs"
24
+ import { Kbd } from "./kbd"
25
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
26
+
27
+ /* ---------------------------------------------------------------- filters */
28
+
29
+ export type FilterOption = { value: string; label: string; count?: number }
30
+
31
+ export function FilterBar({ className, children }: { className?: string; children: React.ReactNode }) {
32
+ return (
33
+ <div role="group" aria-label="Filters" data-slot="filter-bar" className={cn("flex flex-wrap items-center gap-control", className)}>
34
+ {children}
35
+ </div>
36
+ )
37
+ }
38
+
39
+ /** A filter. Dashed while empty (a suggestion), solid once set. Reads "Field operator values". */
40
+ export function FilterPill({
41
+ field,
42
+ operator = "is any of",
43
+ options,
44
+ selected,
45
+ onChange,
46
+ onRemove,
47
+ defaultOpen = false,
48
+ searchable,
49
+ valueNoun,
50
+ }: {
51
+ field: string
52
+ /** How three or more values are counted on the chip, e.g. { one: "seniority", many: "seniorities" }. Falls back to "3 selected". */
53
+ valueNoun?: { one: string; many: string }
54
+ operator?: string
55
+ options: FilterOption[]
56
+ selected: string[]
57
+ /** Called on every tick: list values apply at once. */
58
+ onChange: (values: string[]) => void
59
+ /** Removes the pill. Without it, a set pill clears its values instead. */
60
+ onRemove?: () => void
61
+ defaultOpen?: boolean
62
+ /** Type-ahead inside the list; on by default past 7 options. */
63
+ searchable?: boolean
64
+ }) {
65
+ const [open, setOpen] = React.useState(defaultOpen)
66
+ const set = selected.length > 0
67
+ const labels = options.filter((o) => selected.includes(o.value)).map((o) => o.label)
68
+ // One or two values read in full; three or more are counted, the full list stays in the popover and the name.
69
+ const summary = labels.length <= 2 ? labels.join(", ") : `${labels.length} ${valueNoun?.many ?? "selected"}`
70
+ const spoken = labels.length > 1 ? `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}` : labels[0]
71
+ const toggle = (v: string) => onChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v])
72
+ const remove = onRemove ?? (() => onChange([]))
73
+ const segment = "flex items-center px-2 whitespace-nowrap"
74
+ const focus = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
75
+
76
+ return (
77
+ <Popover open={open} onOpenChange={setOpen}>
78
+ {/* A segmented chip: field | operator | value | remove, one unit with hairlines between the parts. */}
79
+ <div
80
+ data-slot="filter-pill"
81
+ data-state={set ? "set" : "empty"}
82
+ className={cn(
83
+ "inline-flex h-7 items-stretch overflow-hidden rounded-sm border text-base transition-colors duration-(--duration-fast) ease-(--ease-out)",
84
+ set ? "divide-x divide-line border-line-strong bg-surface text-ink shadow-xs" : "border-dashed border-line-strong text-ink-2 hover:border-input-hover hover:text-ink"
85
+ )}
86
+ >
87
+ {set ? (
88
+ <>
89
+ <span className={cn(segment, "text-ink-2")}>{field}</span>
90
+ <span className={cn(segment, "text-ink-3")}>{operator}</span>
91
+ <PopoverTrigger asChild>
92
+ <button
93
+ type="button"
94
+ aria-label={`${field} ${operator} ${spoken}. Change values`}
95
+ onKeyDown={(e) => { if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault(); remove() } }}
96
+ className={cn(segment, "max-w-56 font-medium transition-colors hover:bg-hover", focus)}
97
+ >
98
+ <span className="truncate">{summary}</span>
99
+ </button>
100
+ </PopoverTrigger>
101
+ <button type="button" onClick={remove} aria-label={`Remove ${field} filter`} className={cn("grid w-7 place-items-center text-ink-3 transition-colors hover:bg-hover hover:text-ink", focus)}>
102
+ <X className="size-3.5" aria-hidden="true" />
103
+ </button>
104
+ </>
105
+ ) : (
106
+ <PopoverTrigger asChild>
107
+ <button type="button" className={cn("inline-flex items-center gap-1 px-2 whitespace-nowrap", focus)}>
108
+ <Plus className="size-3.5 text-ink-3" aria-hidden="true" />
109
+ {field}
110
+ </button>
111
+ </PopoverTrigger>
112
+ )}
113
+ </div>
114
+ <PopoverContent align="start" className="w-64 p-0">
115
+ <Command>
116
+ {(searchable ?? options.length > 7) && <CommandInput placeholder={`Search ${field.toLowerCase()}`} />}
117
+ <CommandList>
118
+ <CommandEmpty>No matches</CommandEmpty>
119
+ <CommandGroup heading={`${field} ${operator}`}>
120
+ {options.map((o) => {
121
+ const on = selected.includes(o.value)
122
+ return (
123
+ <CommandItem key={o.value} value={o.label} onSelect={() => toggle(o.value)} aria-checked={on}>
124
+ <Checkbox checked={on} tabIndex={-1} aria-hidden="true" className="pointer-events-none" />
125
+ <span className="truncate">{o.label}</span>
126
+ {o.count !== undefined && <span className="tabular ml-auto text-sm text-ink-3">{o.count}</span>}
127
+ </CommandItem>
128
+ )
129
+ })}
130
+ </CommandGroup>
131
+ </CommandList>
132
+ </Command>
133
+ </PopoverContent>
134
+ </Popover>
135
+ )
136
+ }
137
+
138
+ export type FilterField = {
139
+ id: string
140
+ label: string
141
+ group?: "Most used" | "All fields"
142
+ /** With options, picking the field opens its values beside the list; ticks apply at once through onChange. */
143
+ options?: FilterOption[]
144
+ selected?: string[]
145
+ /** date: picking the field opens typed dates, presets and a calendar beside the list; selected holds dateFilterToValues. */
146
+ kind?: "date"
147
+ presets?: DatePreset[]
148
+ maxDays?: number
149
+ }
150
+
151
+ /**
152
+ * The Filter (F) button: fields first, then, beside them, the chosen field's values with counts.
153
+ * A filter shows as a chip only once a value is applied, so nothing appears in the filter row half-made.
154
+ */
155
+ export function FilterMenu({
156
+ fields,
157
+ onPick,
158
+ onChange,
159
+ label = "Filter",
160
+ variant = "button",
161
+ }: {
162
+ fields: FilterField[]
163
+ /** Fields without options: called when one is picked (the app adds an empty pill). */
164
+ onPick?: (id: string) => void
165
+ /** Fields with options: called on every tick in the values panel. */
166
+ onChange?: (id: string, values: string[]) => void
167
+ label?: string
168
+ /**
169
+ * button: labelled, ghost (the original).
170
+ * icon: a bordered icon button for a toolbar, named by label, F in its tooltip; filters then show on their own row.
171
+ * add: a + after applied filters, to add another one. No shortcut, so it never competes with the toolbar button.
172
+ */
173
+ variant?: "button" | "icon" | "add"
174
+ }) {
175
+ const [open, setOpen] = React.useState(false)
176
+ const [sub, setSub] = React.useState<string | null>(null)
177
+ const groups = ["Most used", "All fields"] as const
178
+ const field = fields.find((f) => f.id === sub)
179
+ const toggle = (f: FilterField, v: string) => {
180
+ const cur = f.selected ?? []
181
+ onChange?.(f.id, cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v])
182
+ }
183
+ return (
184
+ <Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setSub(null) }}>
185
+ {variant !== "button" ? (
186
+ <Tooltip>
187
+ <TooltipTrigger asChild>
188
+ <PopoverTrigger asChild>
189
+ {variant === "icon" ? (
190
+ <Button variant="outline" size="icon-sm" shortcut="F" aria-label={label}><ListFilter /></Button>
191
+ ) : (
192
+ <Button variant="ghost" size="icon-sm" aria-label="Add filter"><Plus /></Button>
193
+ )}
194
+ </PopoverTrigger>
195
+ </TooltipTrigger>
196
+ <TooltipContent>{variant === "icon" ? <>{label} <Kbd>F</Kbd></> : "Add filter"}</TooltipContent>
197
+ </Tooltip>
198
+ ) : (
199
+ <PopoverTrigger asChild>
200
+ <Button variant="ghost" size="sm" shortcut="F">
201
+ <ListFilter />
202
+ {label}
203
+ </Button>
204
+ </PopoverTrigger>
205
+ )}
206
+ <PopoverContent align={variant === "icon" ? "end" : "start"} className="w-60 p-0">
207
+ {/* The values panel anchors to this list and opens beside it. */}
208
+ <Popover open={!!field} onOpenChange={(o) => { if (!o) setSub(null) }}>
209
+ <PopoverAnchor asChild>
210
+ <div>
211
+ <Command>
212
+ <CommandInput placeholder="Filter by…" />
213
+ <CommandList>
214
+ <CommandEmpty>No fields match</CommandEmpty>
215
+ {groups.map((g) => {
216
+ const items = fields.filter((f) => (f.group ?? "All fields") === g)
217
+ if (!items.length) return null
218
+ return (
219
+ <CommandGroup key={g} heading={g}>
220
+ {items.map((f) => {
221
+ const panel = !!f.options || f.kind === "date"
222
+ const date = f.kind === "date" ? describeDateFilter(dateFilterFromValues(f.selected, f.presets), f.presets) : null
223
+ return (
224
+ <CommandItem
225
+ key={f.id}
226
+ value={f.label}
227
+ aria-haspopup={f.kind === "date" ? "dialog" : f.options ? "listbox" : undefined}
228
+ aria-expanded={panel ? sub === f.id : undefined}
229
+ onKeyDown={(e) => { if (e.key === "ArrowRight" && panel) { e.preventDefault(); setSub(f.id) } }}
230
+ onSelect={() => { if (panel) setSub(f.id); else { setOpen(false); onPick?.(f.id) } }}
231
+ className={cn(sub === f.id && "bg-accent")}
232
+ >
233
+ <span className="flex-1 truncate">{f.label}</span>
234
+ {date ? <span className="max-w-24 truncate text-sm text-ink-3">{date.text}</span> : !!f.selected?.length && <span className="tabular text-sm text-ink-3">{f.selected.length}</span>}
235
+ {panel && <ChevronRight className="text-ink-3" />}
236
+ </CommandItem>
237
+ )
238
+ })}
239
+ </CommandGroup>
240
+ )
241
+ })}
242
+ </CommandList>
243
+ </Command>
244
+ </div>
245
+ </PopoverAnchor>
246
+ {field?.kind === "date" && (
247
+ <PopoverContent
248
+ side={variant === "icon" ? "left" : "right"}
249
+ align="start"
250
+ sideOffset={6}
251
+ role="dialog"
252
+ aria-label={`${field.label} dates`}
253
+ className="w-auto max-w-(--radix-popover-content-available-width) p-0"
254
+ onKeyDown={(e) => { if (e.key === "ArrowLeft" && !(e.target instanceof HTMLInputElement)) { e.preventDefault(); setSub(null) } }}
255
+ >
256
+ <DateRangeChooser
257
+ months={1}
258
+ value={dateFilterFromValues(field.selected, field.presets)}
259
+ presets={field.presets}
260
+ maxDays={field.maxDays}
261
+ onApply={(v) => {
262
+ onChange?.(field.id, dateFilterToValues(v))
263
+ setOpen(false)
264
+ setSub(null)
265
+ }}
266
+ />
267
+ </PopoverContent>
268
+ )}
269
+ {field?.options && (
270
+ <PopoverContent
271
+ side={variant === "icon" ? "left" : "right"}
272
+ align="start"
273
+ sideOffset={6}
274
+ className="w-64 p-0"
275
+ onKeyDown={(e) => { if (e.key === "ArrowLeft") { e.preventDefault(); setSub(null) } }}
276
+ >
277
+ <Command>
278
+ {field.options.length > 7 && <CommandInput placeholder={`Search ${field.label.toLowerCase()}`} />}
279
+ <CommandList>
280
+ <CommandEmpty>No matches</CommandEmpty>
281
+ <CommandGroup heading={field.label}>
282
+ {field.options.map((o) => {
283
+ const on = !!field.selected?.includes(o.value)
284
+ return (
285
+ <CommandItem key={o.value} value={o.label} onSelect={() => toggle(field, o.value)} aria-checked={on}>
286
+ <Checkbox checked={on} tabIndex={-1} aria-hidden="true" className="pointer-events-none" />
287
+ <span className="truncate">{o.label}</span>
288
+ {o.count !== undefined && <span className="tabular ml-auto text-sm text-ink-3">{o.count}</span>}
289
+ </CommandItem>
290
+ )
291
+ })}
292
+ </CommandGroup>
293
+ </CommandList>
294
+ </Command>
295
+ </PopoverContent>
296
+ )}
297
+ </Popover>
298
+ </PopoverContent>
299
+ </Popover>
300
+ )
301
+ }
302
+
303
+
304
+ /** Result count and Clear, right after the pills. */
305
+ export function FilterSummary({ shown, total, noun = "rows", onClear }: { shown: number; total: number; noun?: string; onClear?: () => void }) {
306
+ const fmt = (n: number) => n.toLocaleString("en-GB")
307
+ return (
308
+ <span className="ml-1 inline-flex items-center gap-control text-sm text-ink-2">
309
+ <span className="tabular" aria-live="polite">{shown === total ? `${fmt(total)} ${noun}` : `${fmt(shown)} of ${fmt(total)} ${noun}`}</span>
310
+ {onClear && shown !== total && (
311
+ <Button variant="ghost" size="xs" onClick={onClear}>Clear</Button>
312
+ )}
313
+ </span>
314
+ )
315
+ }
316
+
317
+ /* ---------------------------------------------------------------- filter button and row */
318
+
319
+ /** A field people can filter by, with its values, or a date field (kind: "date") filtered by a period. */
320
+ export type FilterFieldDef = {
321
+ id: string
322
+ label: string
323
+ /** The values to tick. Not used for date fields. */
324
+ options?: FilterOption[]
325
+ /**
326
+ * date: filtered by a period, "Applied is in Last 30 days". The field's entry in FilterValue holds
327
+ * dateFilterToValues (["last-30-days"] or ["2026-10-01", "2026-10-31"]); read it with dateFilterFromValues.
328
+ */
329
+ kind?: "date"
330
+ /** Date fields: preset periods. Defaults to DATE_PRESETS; [] shows typed dates and the calendar only. */
331
+ presets?: DatePreset[]
332
+ /** Date fields: longest range, in days. */
333
+ maxDays?: number
334
+ operator?: string
335
+ /** How three or more values are counted on the chip, e.g. { one: "stage", many: "stages" }. */
336
+ valueNoun?: { one: string; many: string }
337
+ group?: "Most used" | "All fields"
338
+ }
339
+
340
+ /** Applied filters: field id to its values, in the order they were added. A field is present only while it has values. */
341
+ export type FilterValue = Record<string, string[]>
342
+
343
+ /** Sets one field's values. An empty list removes the field, so a filter exists only while it filters something. */
344
+ export function setFilterValue(value: FilterValue, id: string, values: string[]): FilterValue {
345
+ const next = { ...value }
346
+ if (values.length) next[id] = values
347
+ else delete next[id]
348
+ return next
349
+ }
350
+
351
+ const toMenuFields = (fields: FilterFieldDef[], value: FilterValue): FilterField[] =>
352
+ fields.map((f) => ({ id: f.id, label: f.label, group: f.group ?? "Most used", options: f.kind === "date" ? undefined : (f.options ?? []), kind: f.kind, presets: f.presets, maxDays: f.maxDays, selected: value[f.id] ?? [] }))
353
+
354
+ /**
355
+ * The toolbar's filter button: fields, then that field's values beside them, applied on tick.
356
+ * Pair it with FilterRow, which shows what is applied on its own row.
357
+ */
358
+ export function FilterButton({ fields, value, onChange, label = "Filter" }: { fields: FilterFieldDef[]; value: FilterValue; onChange: (value: FilterValue) => void; label?: string }) {
359
+ return <FilterMenu variant="icon" label={label} fields={toMenuFields(fields, value)} onChange={(id, v) => onChange(setFilterValue(value, id, v))} />
360
+ }
361
+
362
+ /**
363
+ * The applied filters, on their own row under the toolbar: one chip per field, + for another field, then the count
364
+ * and Clear. Renders nothing while no filter is applied, so the row appears only when it has something to say.
365
+ */
366
+ export function FilterRow({
367
+ fields,
368
+ value,
369
+ onChange,
370
+ shown,
371
+ total,
372
+ noun,
373
+ className,
374
+ }: {
375
+ fields: FilterFieldDef[]
376
+ value: FilterValue
377
+ onChange: (value: FilterValue) => void
378
+ /** Rows showing and in total, for "16 of 150 people" and Clear. */
379
+ shown?: number
380
+ total?: number
381
+ noun?: string
382
+ className?: string
383
+ }) {
384
+ const active = Object.keys(value).filter((id) => value[id]?.length && fields.some((f) => f.id === id))
385
+ if (!active.length) return null
386
+ const rest = fields.filter((f) => !active.includes(f.id))
387
+ return (
388
+ <div data-slot="filter-row" className={cn("flex flex-wrap items-center gap-x-3 gap-y-2", className)}>
389
+ <FilterBar className="min-w-0 flex-1">
390
+ {active.map((id) => {
391
+ const f = fields.find((x) => x.id === id)!
392
+ if (f.kind === "date")
393
+ return (
394
+ <DateFilterPill
395
+ key={id}
396
+ field={f.label}
397
+ value={dateFilterFromValues(value[id], f.presets)}
398
+ onChange={(v) => onChange(setFilterValue(value, id, dateFilterToValues(v)))}
399
+ onRemove={() => onChange(setFilterValue(value, id, []))}
400
+ presets={f.presets}
401
+ maxDays={f.maxDays}
402
+ />
403
+ )
404
+ return (
405
+ <FilterPill
406
+ key={id}
407
+ field={f.label}
408
+ operator={f.operator ?? "is any of"}
409
+ options={f.options ?? []}
410
+ selected={value[id]}
411
+ onChange={(v) => onChange(setFilterValue(value, id, v))}
412
+ onRemove={() => onChange(setFilterValue(value, id, []))}
413
+ valueNoun={f.valueNoun}
414
+ />
415
+ )
416
+ })}
417
+ {rest.length > 0 && <FilterMenu variant="add" fields={toMenuFields(rest, value)} onChange={(id, v) => onChange(setFilterValue(value, id, v))} />}
418
+ </FilterBar>
419
+ {shown !== undefined && total !== undefined && <FilterSummary shown={shown} total={total} noun={noun} onClear={() => onChange({})} />}
420
+ </div>
421
+ )
422
+ }
423
+
424
+ /* ---------------------------------------------------------------- export */
425
+
426
+ export type ExportFormat = "CSV" | "JSON"
427
+
428
+ export function ExportMenu({
429
+ onlyVisible,
430
+ onOnlyVisibleChange,
431
+ includeAi,
432
+ onIncludeAiChange,
433
+ formats = ["CSV", "JSON"],
434
+ onDownload,
435
+ onCopy,
436
+ label = "Export",
437
+ shortcut = "⇧E",
438
+ }: {
439
+ /** The trigger's label. */
440
+ label?: string
441
+ /** Bound to the trigger and shown inside the menu, beside its heading (a menu button keeps its hint in the menu); false for no shortcut. */
442
+ shortcut?: string | false
443
+ onlyVisible: boolean
444
+ onOnlyVisibleChange: (v: boolean) => void
445
+ /** Show the AI columns option only when the table has AI columns. */
446
+ includeAi?: boolean
447
+ onIncludeAiChange?: (v: boolean) => void
448
+ /** Most used first. */
449
+ formats?: ExportFormat[]
450
+ onDownload: (format: ExportFormat) => void
451
+ onCopy?: (format: ExportFormat) => void
452
+ }) {
453
+ const keepOpen = (fn: () => void) => (e: Event) => { e.preventDefault(); fn() }
454
+ // The key works on the trigger; its hint lives in the menu, so the button is just icon and label.
455
+ // Radix opens a menu on pointer-down and key-down, not on click, so the shortcut's programmatic click
456
+ // (detail 0) opens it here. Real clicks and Enter are already handled by Radix and never reach this.
457
+ const [open, setOpen] = React.useState(false)
458
+ const trigger = React.useRef<HTMLButtonElement>(null)
459
+ useShortcut(shortcut || undefined, trigger)
460
+ return (
461
+ <DropdownMenu open={open} onOpenChange={setOpen}>
462
+ <DropdownMenuTrigger asChild>
463
+ <Button ref={trigger} variant="outline" size="sm" onClick={(e) => { if (e.detail === 0) setOpen(true) }} aria-keyshortcuts={shortcut ? shortcut.replace("⇧", "Shift+") : undefined}>
464
+ <Download />
465
+ {label}
466
+ </Button>
467
+ </DropdownMenuTrigger>
468
+ <DropdownMenuContent align="end" className="w-56">
469
+ <DropdownMenuLabel className="flex items-center justify-between gap-3 text-ink">
470
+ {label}
471
+ {shortcut && <Kbd>{shortcut}</Kbd>}
472
+ </DropdownMenuLabel>
473
+ <DropdownMenuSeparator />
474
+ <DropdownMenuLabel>Options</DropdownMenuLabel>
475
+ <DropdownMenuCheckboxItem checked={onlyVisible} onSelect={keepOpen(() => onOnlyVisibleChange(!onlyVisible))}>Only visible columns</DropdownMenuCheckboxItem>
476
+ {onIncludeAiChange && (
477
+ <DropdownMenuCheckboxItem checked={!!includeAi} onSelect={keepOpen(() => onIncludeAiChange(!includeAi))}>Include AI columns</DropdownMenuCheckboxItem>
478
+ )}
479
+ <DropdownMenuSeparator />
480
+ <DropdownMenuLabel>Download</DropdownMenuLabel>
481
+ {formats.map((f) => <DropdownMenuItem key={`d${f}`} onSelect={() => onDownload(f)}>Download {f}</DropdownMenuItem>)}
482
+ {onCopy && (
483
+ <>
484
+ <DropdownMenuSeparator />
485
+ <DropdownMenuLabel>Copy</DropdownMenuLabel>
486
+ {formats.map((f) => <DropdownMenuItem key={`c${f}`} onSelect={() => onCopy(f)}>Copy as {f}</DropdownMenuItem>)}
487
+ </>
488
+ )}
489
+ </DropdownMenuContent>
490
+ </DropdownMenu>
491
+ )
492
+ }
493
+
494
+ /* ---------------------------------------------------------------- grouped list */
495
+
496
+ export function GroupedList({ className, children, ...props }: React.ComponentProps<"div">) {
497
+ return <div data-slot="grouped-list" className={cn("overflow-hidden rounded-sm border border-line bg-surface", className)} {...props}>{children}</div>
498
+ }
499
+
500
+ export function ListGroup({ title, count, action, defaultOpen = true, children }: { title: string; count: number; action?: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode }) {
501
+ const [open, setOpen] = React.useState(defaultOpen)
502
+ return (
503
+ <Collapsible open={open} onOpenChange={setOpen} data-slot="list-group" className="border-b border-line last:border-b-0">
504
+ <div className="flex h-8 items-center gap-2 bg-surface-2 pr-2 pl-1">
505
+ <CollapsibleTrigger asChild>
506
+ <button type="button" className="flex h-full min-w-0 flex-1 items-center gap-control rounded-xs px-2 text-left text-sm font-medium text-ink">
507
+ <ChevronRight className={cn("size-3.5 shrink-0 text-ink-3 transition-transform duration-(--duration-fast) ease-(--ease-out)", open && "rotate-90")} aria-hidden="true" />
508
+ <span className="truncate">{title}</span>
509
+ <span className="tabular font-normal text-ink-3">{count}</span>
510
+ </button>
511
+ </CollapsibleTrigger>
512
+ {action}
513
+ </div>
514
+ <CollapsibleContent>
515
+ <ul className="divide-y divide-line">{children}</ul>
516
+ </CollapsibleContent>
517
+ </Collapsible>
518
+ )
519
+ }
520
+
521
+ /* ---------------------------------------------------------------- saved views */
522
+
523
+ export type SavedView = { id: string; name: string; count?: number }
524
+
525
+ /** A view action that can't run right now: focusable, announced as unavailable, with its reason under the label. */
526
+ function UnavailableItem({ icon, label, reason }: { icon: React.ReactNode; label: string; reason: string }) {
527
+ return (
528
+ <DropdownMenuItem aria-disabled="true" onSelect={(e) => e.preventDefault()} className="items-start text-ink-3 focus:bg-hover focus:text-ink-3">
529
+ {icon}
530
+ <span className="grid gap-0.5">
531
+ {label}
532
+ <span className="text-sm text-ink-3">{reason}</span>
533
+ </span>
534
+ </DropdownMenuItem>
535
+ )
536
+ }
537
+
538
+ export function SavedViews({
539
+ views,
540
+ value,
541
+ onValueChange,
542
+ dirty = false,
543
+ onReset,
544
+ onSave,
545
+ onSaveAs,
546
+ onRename,
547
+ onDelete,
548
+ defaultId,
549
+ onSetDefault,
550
+ className,
551
+ }: {
552
+ views: SavedView[]
553
+ value: string
554
+ onValueChange: (id: string) => void
555
+ /** Filters, sort or columns differ from the saved view. */
556
+ dirty?: boolean
557
+ onReset?: () => void
558
+ /** Update the current view. */
559
+ onSave?: () => void
560
+ /** Save the current state as a new view. */
561
+ onSaveAs?: (name: string) => void
562
+ /** Rename a view. Names are unique; the dialog checks before it calls this. */
563
+ onRename?: (id: string, name: string) => void
564
+ /** Delete a view at once. Show a toast with Undo: deleting a view is reversible, so there is no dialog. */
565
+ onDelete?: (id: string) => void
566
+ /** The view that loads when the page opens without filters in the URL. */
567
+ defaultId?: string
568
+ onSetDefault?: (id: string) => void
569
+ className?: string
570
+ }) {
571
+ // One name dialog for Save view and Rename view, validated on submit (never a disabled submit button).
572
+ const [naming, setNaming] = React.useState<null | "save" | "rename">(null)
573
+ const [name, setName] = React.useState("")
574
+ const [error, setError] = React.useState("")
575
+ const fieldId = React.useId()
576
+ // The dialog opens from a button or from a menu item that is gone once the menu closes: focus returns to the button that started it.
577
+ const saveRef = React.useRef<HTMLButtonElement>(null)
578
+ const menuRef = React.useRef<HTMLButtonElement>(null)
579
+ const returnTo = React.useRef<HTMLButtonElement | null>(null)
580
+ const current = views.find((v) => v.id === value)
581
+ const openNaming = (mode: "save" | "rename", from: React.RefObject<HTMLButtonElement | null>) => { returnTo.current = from.current; setName(mode === "rename" ? current?.name ?? "" : ""); setError(""); setNaming(mode) }
582
+ const submit = (e: React.FormEvent) => {
583
+ e.preventDefault()
584
+ const n = name.trim()
585
+ if (!n) return setError("Enter a name for the view.")
586
+ const taken = views.some((v) => v.name.toLowerCase() === n.toLowerCase() && !(naming === "rename" && v.id === value))
587
+ if (taken) return setError(`A view called “${n}” already exists. Choose another name.`)
588
+ if (naming === "rename") { if (n !== current?.name) onRename?.(value, n) }
589
+ else onSaveAs?.(n)
590
+ setNaming(null)
591
+ }
592
+ const manage = !!(onRename || onDelete || onSetDefault)
593
+ return (
594
+ // The tabs keep their natural width. When the actions don't fit beside them, they wrap onto a line above
595
+ // (wrap-reverse), so the tabs stay on the bottom border; tabs wider than the space scroll in their own box.
596
+ <div data-slot="saved-views" className={cn("flex flex-wrap-reverse items-end justify-end gap-x-3 border-b border-line", className)}>
597
+ <Tabs value={value} onValueChange={onValueChange} className="max-w-full min-w-0 flex-auto">
598
+ <TabsList aria-label="Saved views" className="max-w-full overflow-x-auto border-b-0">
599
+ {views.map((v) => (
600
+ <TabsTrigger key={v.id} value={v.id} count={v.count}>
601
+ {v.name}
602
+ {v.id === defaultId && <span className="sr-only">(default view)</span>}
603
+ </TabsTrigger>
604
+ ))}
605
+ </TabsList>
606
+ </Tabs>
607
+ <div className="flex max-w-full flex-wrap items-center justify-end gap-control pb-2">
608
+ {dirty && (
609
+ <>
610
+ <Badge variant="warning" dot>Unsaved changes</Badge>
611
+ {onReset && <Button variant="ghost" size="sm" onClick={onReset}>Reset</Button>}
612
+ {onSave && <Button variant="outline" size="sm" onClick={onSave}>Update “{current?.name}”</Button>}
613
+ </>
614
+ )}
615
+ {onSaveAs && <Button ref={saveRef} variant="outline" size="sm" onClick={() => openNaming("save", saveRef)}>Save view</Button>}
616
+ {manage && current && (
617
+ <DropdownMenu>
618
+ <DropdownMenuTrigger asChild>
619
+ <Button ref={menuRef} variant="ghost" size="icon-sm" aria-label={`Manage view “${current.name}”`} className="data-[state=open]:bg-hover">
620
+ <EllipsisVertical />
621
+ </Button>
622
+ </DropdownMenuTrigger>
623
+ <DropdownMenuContent align="end" className="w-60">
624
+ <DropdownMenuLabel className="truncate">{current.name}{current.id === defaultId ? " · Default" : ""}</DropdownMenuLabel>
625
+ {onSave && (dirty
626
+ ? <DropdownMenuItem onSelect={onSave}><Save />Update view</DropdownMenuItem>
627
+ : <UnavailableItem icon={<Save />} label="Update view" reason="No changes to save." />)}
628
+ {onSaveAs && <DropdownMenuItem onSelect={() => openNaming("save", menuRef)}><Plus />Save as new view</DropdownMenuItem>}
629
+ {onRename && <DropdownMenuItem onSelect={() => openNaming("rename", menuRef)}><Pencil />Rename view</DropdownMenuItem>}
630
+ {onSetDefault && (current.id === defaultId
631
+ ? <UnavailableItem icon={<Star />} label="Set as default" reason="This is already your default view." />
632
+ : dirty
633
+ ? <UnavailableItem icon={<Star />} label="Set as default" reason="Update or reset your changes first." />
634
+ : <DropdownMenuItem onSelect={() => onSetDefault(current.id)}><Star />Set as default</DropdownMenuItem>)}
635
+ {onDelete && (
636
+ <>
637
+ <DropdownMenuSeparator />
638
+ {views.length > 1
639
+ ? <DropdownMenuItem variant="destructive" onSelect={() => onDelete(current.id)}><Trash2 />Delete view</DropdownMenuItem>
640
+ : <UnavailableItem icon={<Trash2 />} label="Delete view" reason="The only view can't be deleted." />}
641
+ </>
642
+ )}
643
+ </DropdownMenuContent>
644
+ </DropdownMenu>
645
+ )}
646
+ </div>
647
+ <Dialog open={naming !== null} onOpenChange={(o) => !o && setNaming(null)}>
648
+ <DialogContent className="sm:max-w-sm" onCloseAutoFocus={(e) => { if (returnTo.current) { e.preventDefault(); returnTo.current.focus() } }}>
649
+ <DialogHeader>
650
+ <DialogTitle>{naming === "rename" ? "Rename view" : "Save view"}</DialogTitle>
651
+ <DialogDescription>{naming === "rename" ? "The view keeps its filters, sort order and columns." : "Saves the current filters, sort order and columns."}</DialogDescription>
652
+ </DialogHeader>
653
+ <form noValidate className="grid gap-4" onSubmit={submit}>
654
+ <div className="grid gap-2">
655
+ <label htmlFor={fieldId} className="text-base font-medium text-ink">Name</label>
656
+ <Input
657
+ id={fieldId}
658
+ value={name}
659
+ onChange={(e) => { setName(e.target.value); if (error) setError("") }}
660
+ placeholder="Closing this month"
661
+ aria-invalid={!!error || undefined}
662
+ aria-describedby={error ? `${fieldId}-error` : undefined}
663
+ autoFocus
664
+ />
665
+ {error && <p id={`${fieldId}-error`} className="text-sm text-bad">{error}</p>}
666
+ </div>
667
+ <DialogFooter>
668
+ <DialogClose asChild><Button type="button" variant="outline">Cancel</Button></DialogClose>
669
+ <Button type="submit">{naming === "rename" ? "Rename view" : "Save view"}</Button>
670
+ </DialogFooter>
671
+ </form>
672
+ </DialogContent>
673
+ </Dialog>
674
+ </div>
675
+ )
676
+ }
677
+
678
+ /* ---------------------------------------------------------------- filter panel */
679
+
680
+ export type FilterSection = { id: string; title: string; options: FilterOption[]; defaultOpen?: boolean }
681
+
682
+ /** Many-field search: collapsible sections, live counts per option, Clear all and a clear per section. */
683
+ export function FilterPanel({
684
+ sections,
685
+ value,
686
+ onChange,
687
+ className,
688
+ }: {
689
+ sections: FilterSection[]
690
+ value: Record<string, string[]>
691
+ onChange: (value: Record<string, string[]>) => void
692
+ className?: string
693
+ }) {
694
+ const active = Object.values(value).reduce((n, v) => n + v.length, 0)
695
+ const set = (id: string, vals: string[]) => onChange({ ...value, [id]: vals })
696
+ return (
697
+ <div data-slot="filter-panel" className={cn("flex min-h-0 flex-col", className)}>
698
+ <div className="flex items-center gap-2 border-b border-line px-3 py-2">
699
+ <h2 className="text-base font-semibold text-ink">Filters</h2>
700
+ {active > 0 && <Badge variant="secondary" className="tabular">{active}</Badge>}
701
+ <span className="flex-1" />
702
+ {active > 0 && <Button variant="ghost" size="xs" onClick={() => onChange({})}>Clear all</Button>}
703
+ </div>
704
+ <div className="min-h-0 flex-1 overflow-y-auto">
705
+ {sections.map((s) => {
706
+ const sel = value[s.id] ?? []
707
+ return (
708
+ <Collapsible key={s.id} defaultOpen={s.defaultOpen} className="group/sec border-b border-line">
709
+ <div className="flex items-center pr-2">
710
+ <CollapsibleTrigger asChild>
711
+ <button type="button" className="flex h-9 min-w-0 flex-1 items-center gap-control rounded-xs px-3 text-left text-base font-medium text-ink">
712
+ <ChevronRight className="size-3.5 shrink-0 text-ink-3 transition-transform duration-(--duration-fast) group-data-[state=open]/sec:rotate-90" aria-hidden="true" />
713
+ <span className="truncate">{s.title}</span>
714
+ {sel.length > 0 && <span className="tabular text-sm font-normal text-primary-ink">{sel.length} set</span>}
715
+ </button>
716
+ </CollapsibleTrigger>
717
+ {sel.length > 0 && <Button variant="ghost" size="xs" onClick={() => set(s.id, [])} aria-label={`Clear ${s.title}`}>Clear</Button>}
718
+ </div>
719
+ <CollapsibleContent>
720
+ <ul className="grid gap-0.5 px-2 pb-2">
721
+ {s.options.map((o) => {
722
+ const on = sel.includes(o.value)
723
+ const id = `fp-${s.id}-${o.value}`.replace(/\W+/g, "-")
724
+ return (
725
+ <li key={o.value} className="flex h-7 items-center gap-2 rounded-xs px-2 hover:bg-hover">
726
+ <Checkbox id={id} checked={on} onCheckedChange={() => set(s.id, on ? sel.filter((x) => x !== o.value) : [...sel, o.value])} />
727
+ <label htmlFor={id} className="min-w-0 flex-1 truncate text-base text-ink">{o.label}</label>
728
+ {o.count !== undefined && <span className="tabular text-sm text-ink-3">{o.count}</span>}
729
+ </li>
730
+ )
731
+ })}
732
+ </ul>
733
+ </CollapsibleContent>
734
+ </Collapsible>
735
+ )
736
+ })}
737
+ </div>
738
+ </div>
739
+ )
740
+ }