@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,686 @@
1
+ /**
2
+ * DatePicker, DateRangePicker and DateFilterPill: TwoUX compositions on the generated shadcn Calendar (react-day-picker), Popover and InputGroup.
3
+ * Typing is the main path; the calendar is the second. Values are ISO calendar dates ("2026-03-12"), never a Date at
4
+ * midnight UTC, so they match Timestamp, which reads that form as a local day. Errors sit under the field.
5
+ * Proposal: proposals/date-picker.md.
6
+ */
7
+ "use client"
8
+
9
+ import { AlertCircle, CalendarDays, Plus, X } from "lucide-react"
10
+ import * as React from "react"
11
+ import type { DateRange as DayRange, Matcher } from "react-day-picker"
12
+ import { cn } from "../lib/cn"
13
+ import { Button } from "./button"
14
+ import { Calendar } from "./calendar"
15
+ import { Field, FieldDescription, FieldLabel } from "./field"
16
+ import { Input } from "./input"
17
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText } from "./input-group"
18
+ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "./popover"
19
+
20
+ /* ---------------------------------------------------------------- dates as ISO strings */
21
+
22
+ const pad = (n: number) => String(n).padStart(2, "0")
23
+ /** A local Date as an ISO calendar date: 2026-03-12. */
24
+ const isoOf = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
25
+ /** An ISO calendar date as a local Date at midnight (not UTC, which lands on the day before west of London). */
26
+ export const fromIsoDate = (iso: string) => {
27
+ const [y, m, d] = iso.split("-").map(Number)
28
+ return new Date(y, m - 1, d)
29
+ }
30
+ const valid = (y: number, m: number, d: number) => {
31
+ const t = new Date(y, m - 1, d)
32
+ return t.getFullYear() === y && t.getMonth() === m - 1 && t.getDate() === d ? isoOf(t) : null
33
+ }
34
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
35
+ const year = (y: string) => (y.length === 2 ? 2000 + Number(y) : Number(y))
36
+
37
+ /**
38
+ * Reads a typed date the way people in the UK write it: 12/03/2026, 12/3/26, 12-03-2026, 12.03.2026, 12 Mar 2026,
39
+ * 12 March 2026 and ISO 2026-03-12. Returns the ISO date, or null when it isn't a real date.
40
+ */
41
+ export function parseDate(text: string): string | null {
42
+ const s = text.trim().toLowerCase().replace(/,/g, " ").replace(/\s+/g, " ")
43
+ if (!s) return null
44
+ let m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s)
45
+ if (m) return valid(+m[1], +m[2], +m[3])
46
+ m = /^(\d{1,2})[/.\-](\d{1,2})[/.\-](\d{2}|\d{4})$/.exec(s)
47
+ if (m) return valid(year(m[3]), +m[2], +m[1])
48
+ m = /^(\d{1,2})(?:st|nd|rd|th)? ([a-z]+)\.? (\d{2}|\d{4})$/.exec(s)
49
+ if (m) {
50
+ const month = MONTHS.indexOf(m[2].slice(0, 3))
51
+ return month < 0 || (m[2].length > 3 && !["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"][month].startsWith(m[2])) ? null : valid(year(m[3]), month + 1, +m[1])
52
+ }
53
+ return null
54
+ }
55
+ /** The ISO date as it appears in the field: 12/03/2026. */
56
+ export const formatDateInput = (iso: string | null | undefined) => (iso ? iso.split("-").reverse().join("/") : "")
57
+ /** The ISO date in words, for messages: 7 December 2026. */
58
+ export const formatDateLong = (iso: string) => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" }).format(fromIsoDate(iso))
59
+
60
+ /* ---------------------------------------------------------------- presets */
61
+
62
+ export type DatePreset = {
63
+ id: string
64
+ label: string
65
+ /** The range on a given day. Stored by id in filters, so a saved view still means "last 30 days" next month. */
66
+ range: (today: Date) => { from: string; to: string }
67
+ }
68
+ const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
69
+ const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1)
70
+ /** Last 7 days, last 30 days, this month, last month, this quarter and last quarter, each ending today or at the period's end. */
71
+ export const DATE_PRESETS: DatePreset[] = [
72
+ { id: "last-7-days", label: "Last 7 days", range: (t) => ({ from: isoOf(addDays(t, -6)), to: isoOf(t) }) },
73
+ { id: "last-30-days", label: "Last 30 days", range: (t) => ({ from: isoOf(addDays(t, -29)), to: isoOf(t) }) },
74
+ { id: "this-month", label: "This month", range: (t) => ({ from: isoOf(new Date(t.getFullYear(), t.getMonth(), 1)), to: isoOf(t) }) },
75
+ { id: "last-month", label: "Last month", range: (t) => ({ from: isoOf(new Date(t.getFullYear(), t.getMonth() - 1, 1)), to: isoOf(new Date(t.getFullYear(), t.getMonth(), 0)) }) },
76
+ { id: "this-quarter", label: "This quarter", range: (t) => ({ from: isoOf(quarterStart(t)), to: isoOf(t) }) },
77
+ {
78
+ id: "last-quarter",
79
+ label: "Last quarter",
80
+ range: (t) => {
81
+ const start = quarterStart(t)
82
+ return { from: isoOf(new Date(start.getFullYear(), start.getMonth() - 3, 1)), to: isoOf(addDays(start, -1)) }
83
+ },
84
+ },
85
+ ]
86
+ /** The dates a range value stands for today: a preset is worked out afresh, fixed dates are returned as they are. */
87
+ export function resolveDateRange(value: DateRangeValue, presets: DatePreset[] = DATE_PRESETS, today = new Date()): { from: string | null; to: string | null } {
88
+ const p = value.preset ? presets.find((x) => x.id === value.preset) : undefined
89
+ return p ? p.range(today) : { from: value.from ?? null, to: value.to ?? null }
90
+ }
91
+
92
+ /* ---------------------------------------------------------------- shared parts */
93
+
94
+ type Unavailable = {
95
+ /** Earliest date people can choose, as an ISO date. */
96
+ min?: string
97
+ /** Latest date people can choose, as an ISO date. */
98
+ max?: string
99
+ /** Other dates people can't choose, such as weekends: true means unavailable. */
100
+ isDateUnavailable?: (iso: string) => boolean
101
+ /** One sentence under the calendar, and the error when such a date is typed: "Closing dates must be on a weekday." */
102
+ unavailableReason?: string
103
+ }
104
+ function checkDate(iso: string, { min, max, isDateUnavailable, unavailableReason }: Unavailable): string | undefined {
105
+ if (min && iso < min) return `Choose a date on or after ${formatDateLong(min)}.`
106
+ if (max && iso > max) return `Choose a date on or before ${formatDateLong(max)}.`
107
+ if (isDateUnavailable?.(iso)) return unavailableReason ?? "That date isn't available. Choose another."
108
+ }
109
+ function matchers({ min, max, isDateUnavailable }: Unavailable): Matcher[] {
110
+ const m: Matcher[] = []
111
+ if (min) m.push({ before: fromIsoDate(min) })
112
+ if (max) m.push({ after: fromIsoDate(max) })
113
+ if (isDateUnavailable) m.push((d: Date) => isDateUnavailable(isoOf(d)))
114
+ return m
115
+ }
116
+ const formatHelp = (what: string) => `Enter the ${what.charAt(0).toLowerCase() + what.slice(1)} as dd/mm/yyyy, such as 12/03/2026.`
117
+
118
+ function Message({ id, error, hint }: { id: string; error?: string; hint?: React.ReactNode }) {
119
+ if (error)
120
+ return (
121
+ <p id={id} className="flex items-start gap-control text-sm text-bad">
122
+ <AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
123
+ {error}
124
+ </p>
125
+ )
126
+ return hint ? <FieldDescription id={id}>{hint}</FieldDescription> : null
127
+ }
128
+ function Label({ htmlFor, id, label, required, optional }: { htmlFor?: string; id: string; label: string; required?: boolean; optional?: boolean }) {
129
+ return (
130
+ <FieldLabel htmlFor={htmlFor} id={id}>
131
+ {label}
132
+ {required && <span aria-hidden="true" className="text-bad">*</span>}
133
+ {optional && <span className="font-normal text-ink-3">(optional)</span>}
134
+ </FieldLabel>
135
+ )
136
+ }
137
+ function useWide() {
138
+ const [wide, setWide] = React.useState(false)
139
+ React.useEffect(() => {
140
+ const q = window.matchMedia("(min-width: 768px)")
141
+ const on = () => setWide(q.matches)
142
+ on()
143
+ q.addEventListener("change", on)
144
+ return () => q.removeEventListener("change", on)
145
+ }, [])
146
+ return wide
147
+ }
148
+
149
+ /* ---------------------------------------------------------------- DatePicker */
150
+
151
+ type DatePickerProps = Unavailable & {
152
+ label: string
153
+ /** ISO date, or null when empty. */
154
+ value: string | null
155
+ onValueChange: (value: string | null) => void
156
+ /** Shown under the field. Defaults to the format, "dd/mm/yyyy". */
157
+ hint?: string
158
+ /** An error from the form's own checks, such as "Enter a closing date." Typing errors are shown without it. */
159
+ error?: string
160
+ required?: boolean
161
+ optional?: boolean
162
+ disabled?: boolean
163
+ /** Shows the value as text that can be selected and copied, without the calendar button. */
164
+ readOnly?: boolean
165
+ id?: string
166
+ /** Submitted with the form as the ISO date. */
167
+ name?: string
168
+ className?: string
169
+ }
170
+
171
+ /** One calendar date: type it as dd/mm/yyyy or choose it on the calendar (Alt+↓). */
172
+ export function DatePicker({ label, value, onValueChange, hint = "dd/mm/yyyy", error, required, optional, disabled, readOnly, id, name, className, ...rules }: DatePickerProps) {
173
+ const auto = React.useId()
174
+ const fid = id ?? auto
175
+ const inputRef = React.useRef<HTMLInputElement>(null)
176
+ const [text, setText] = React.useState(formatDateInput(value))
177
+ const [typingError, setTypingError] = React.useState<string>()
178
+ const [open, setOpen] = React.useState(false)
179
+ React.useEffect(() => setText(formatDateInput(value)), [value])
180
+
181
+ const commit = () => {
182
+ if (!text.trim()) {
183
+ setTypingError(undefined)
184
+ if (value !== null) onValueChange(null)
185
+ return
186
+ }
187
+ const iso = parseDate(text)
188
+ if (!iso) {
189
+ setTypingError(formatHelp(label))
190
+ if (value !== null) onValueChange(null)
191
+ return
192
+ }
193
+ setTypingError(checkDate(iso, rules))
194
+ setText(formatDateInput(iso))
195
+ if (iso !== value) onValueChange(iso)
196
+ }
197
+ const shown = typingError ?? error
198
+ const msgId = `${fid}-msg`
199
+
200
+ if (readOnly)
201
+ return (
202
+ <Field className={cn("gap-2", className)}>
203
+ <Label id={`${fid}-label`} htmlFor={fid} label={label} />
204
+ <InputGroup>
205
+ <InputGroupInput id={fid} readOnly value={formatDateInput(value)} name={name} />
206
+ </InputGroup>
207
+ </Field>
208
+ )
209
+
210
+ return (
211
+ <Field data-invalid={!!shown || undefined} className={cn("gap-2", className)}>
212
+ <Label id={`${fid}-label`} htmlFor={fid} label={label} required={required} optional={optional} />
213
+ <Popover open={open} onOpenChange={setOpen}>
214
+ <PopoverAnchor asChild>
215
+ <InputGroup data-disabled={disabled || undefined}>
216
+ <InputGroupInput
217
+ ref={inputRef}
218
+ id={fid}
219
+ inputMode="numeric"
220
+ autoComplete="off"
221
+ placeholder="dd/mm/yyyy"
222
+ value={text}
223
+ disabled={disabled}
224
+ required={required}
225
+ aria-invalid={shown ? true : undefined}
226
+ aria-describedby={msgId}
227
+ onChange={(e) => setText(e.target.value)}
228
+ onBlur={commit}
229
+ onKeyDown={(e) => {
230
+ if (e.key === "Enter") commit()
231
+ if (e.key === "ArrowDown" && e.altKey) {
232
+ e.preventDefault()
233
+ commit()
234
+ setOpen(true)
235
+ }
236
+ }}
237
+ />
238
+ <InputGroupAddon align="inline-end">
239
+ <InputGroupButton size="icon-xs" aria-label="Choose date" aria-haspopup="dialog" aria-expanded={open} disabled={disabled} onClick={() => setOpen((o) => !o)}>
240
+ <CalendarDays />
241
+ </InputGroupButton>
242
+ </InputGroupAddon>
243
+ </InputGroup>
244
+ </PopoverAnchor>
245
+ <PopoverContent
246
+ align="end"
247
+ className="w-auto p-0"
248
+ role="dialog"
249
+ aria-label="Choose date"
250
+ onOpenAutoFocus={(e) => e.preventDefault()}
251
+ onCloseAutoFocus={(e) => {
252
+ e.preventDefault()
253
+ inputRef.current?.focus()
254
+ }}
255
+ >
256
+ <Calendar
257
+ mode="single"
258
+ autoFocus
259
+ selected={value ? fromIsoDate(value) : undefined}
260
+ defaultMonth={value ? fromIsoDate(value) : rules.min && new Date() < fromIsoDate(rules.min) ? fromIsoDate(rules.min) : undefined}
261
+ disabled={matchers(rules)}
262
+ footer={rules.unavailableReason ? <p className="max-w-60 pt-2 text-sm text-ink-2">{rules.unavailableReason}</p> : undefined}
263
+ onSelect={(d) => {
264
+ if (!d) return
265
+ const iso = isoOf(d)
266
+ setTypingError(undefined)
267
+ setText(formatDateInput(iso))
268
+ onValueChange(iso)
269
+ setOpen(false)
270
+ }}
271
+ />
272
+ </PopoverContent>
273
+ </Popover>
274
+ {name && <input type="hidden" name={name} value={value ?? ""} />}
275
+ <Message id={msgId} error={shown} hint={hint} />
276
+ </Field>
277
+ )
278
+ }
279
+
280
+ /* ---------------------------------------------------------------- ranges: shared value, checks and calendar body */
281
+
282
+ export type DateRangeValue = {
283
+ from?: string | null
284
+ to?: string | null
285
+ /** A preset id ("last-30-days"): the range is worked out from today each time it is read. */
286
+ preset?: string
287
+ }
288
+
289
+ const dayCount = (from: string, to: string) => Math.round((fromIsoDate(to).getTime() - fromIsoDate(from).getTime()) / 86400000) + 1
290
+ function checkRange(from: string | null | undefined, to: string | null | undefined, rules: Unavailable, maxDays?: number) {
291
+ for (const d of [from, to]) if (d) { const e = checkDate(d, rules); if (e) return e }
292
+ if (from && to && to < from) return "The end date is before the start date. Change one of them."
293
+ if (from && to && maxDays && dayCount(from, to) > maxDays) return `Choose a range of up to ${maxDays} days.`
294
+ }
295
+ /** Reads typed From and To text: the ISO dates, or which end isn't a date. */
296
+ function readTyped(fromText: string, toText: string): { from: string | null; to: string | null } | { bad: "start" | "end" } {
297
+ const from = fromText.trim() ? parseDate(fromText) : null
298
+ const to = toText.trim() ? parseDate(toText) : null
299
+ if (fromText.trim() && !from) return { bad: "start" }
300
+ if (toText.trim() && !to) return { bad: "end" }
301
+ return { from, to }
302
+ }
303
+
304
+ /** Presets beside a range calendar. The first day picked starts the range, the second ends it; days between fill as people point or move. */
305
+ function RangeBody({ value, presets, maxDays, months = 2, rules, onStart, onRange, onPreset }: {
306
+ value: DateRangeValue
307
+ months?: 1 | 2
308
+ presets: DatePreset[]
309
+ maxDays?: number
310
+ rules: Unavailable
311
+ onStart?: (iso: string) => void
312
+ onRange: (from: string, to: string) => void
313
+ onPreset: (p: DatePreset) => void
314
+ }) {
315
+ const resolved = resolveDateRange(value, presets.length ? presets : DATE_PRESETS)
316
+ const [picking, setPicking] = React.useState<string | null>(null)
317
+ const [hover, setHover] = React.useState<string | null>(null)
318
+ const wide = useWide() && months === 2
319
+ const choose = (d: Date) => {
320
+ const iso = isoOf(d)
321
+ if (!picking) {
322
+ setPicking(iso)
323
+ onStart?.(iso)
324
+ return
325
+ }
326
+ const [from, to] = iso < picking ? [iso, picking] : [picking, iso]
327
+ setPicking(null)
328
+ setHover(null)
329
+ onRange(from, to)
330
+ }
331
+ const lo = picking && hover ? (hover < picking ? hover : picking) : null
332
+ const hi = picking && hover ? (hover < picking ? picking : hover) : null
333
+ const selected: DayRange | undefined = picking
334
+ ? { from: fromIsoDate(picking), to: undefined }
335
+ : resolved.from
336
+ ? { from: fromIsoDate(resolved.from), to: resolved.to ? fromIsoDate(resolved.to) : undefined }
337
+ : undefined
338
+ return (
339
+ <div className="flex flex-col md:flex-row">
340
+ {presets.length > 0 && (
341
+ <ul aria-label="Preset periods" className="flex flex-wrap gap-1 border-b border-line p-2 md:w-36 md:flex-col md:flex-nowrap md:border-r md:border-b-0">
342
+ {presets.map((p) => (
343
+ <li key={p.id}>
344
+ <button
345
+ type="button"
346
+ aria-pressed={value.preset === p.id}
347
+ onClick={() => onPreset(p)}
348
+ className="w-full rounded-xs px-2 py-1 text-left text-base text-ink-2 transition-colors duration-(--duration-fast) hover:bg-hover hover:text-ink focus-visible:outline-2 focus-visible:outline-ring aria-pressed:bg-selected aria-pressed:text-ink"
349
+ >
350
+ {p.label}
351
+ </button>
352
+ </li>
353
+ ))}
354
+ </ul>
355
+ )}
356
+ <Calendar
357
+ mode="range"
358
+ autoFocus
359
+ numberOfMonths={wide ? 2 : 1}
360
+ showOutsideDays={!wide}
361
+ selected={selected}
362
+ // A no-op onSelect makes the calendar show this component's range; without it react-day-picker keeps its own.
363
+ onSelect={() => {}}
364
+ defaultMonth={resolved.from ? fromIsoDate(resolved.from) : undefined}
365
+ disabled={matchers(rules)}
366
+ modifiers={{ preview: lo && hi ? { from: fromIsoDate(lo), to: fromIsoDate(hi) } : [] }}
367
+ modifiersClassNames={{ preview: "bg-selected" }}
368
+ onDayMouseEnter={(d) => picking && setHover(isoOf(d))}
369
+ onDayFocus={(d) => picking && setHover(isoOf(d))}
370
+ onDayClick={(d, m) => !m.disabled && choose(d)}
371
+ footer={
372
+ <p className="max-w-72 pt-2 text-sm text-ink-2">
373
+ {picking ? "Choose the end date." : rules.unavailableReason ?? (maxDays ? `Up to ${maxDays} days.` : "Choose the start date.")}
374
+ </p>
375
+ }
376
+ />
377
+ </div>
378
+ )
379
+ }
380
+
381
+ /* ---------------------------------------------------------------- DateRangePicker */
382
+
383
+ type DateRangePickerProps = Unavailable & {
384
+ label: string
385
+ value: DateRangeValue
386
+ onValueChange: (value: DateRangeValue) => void
387
+ /** Preset periods beside the calendar. true uses DATE_PRESETS. */
388
+ presets?: DatePreset[] | boolean
389
+ /** Longest range people can choose, in days, counting both ends. */
390
+ maxDays?: number
391
+ hint?: string
392
+ error?: string
393
+ required?: boolean
394
+ optional?: boolean
395
+ disabled?: boolean
396
+ id?: string
397
+ className?: string
398
+ }
399
+
400
+ /** A from–to range of dates: type both ends, choose them on the calendar, or pick a preset such as Last 30 days. */
401
+ export function DateRangePicker({ label, value, onValueChange, presets, maxDays, hint, error, required, optional, disabled, id, className, ...rules }: DateRangePickerProps) {
402
+ const auto = React.useId()
403
+ const fid = id ?? auto
404
+ const list = presets === true ? DATE_PRESETS : presets || []
405
+ const resolved = resolveDateRange(value, list.length ? list : DATE_PRESETS)
406
+ const preset = value.preset ? list.find((p) => p.id === value.preset) : undefined
407
+ const fromRef = React.useRef<HTMLInputElement>(null)
408
+ const [fromText, setFromText] = React.useState(formatDateInput(resolved.from))
409
+ const [toText, setToText] = React.useState(formatDateInput(resolved.to))
410
+ const [typingError, setTypingError] = React.useState<string>()
411
+ const [open, setOpen] = React.useState(false)
412
+ React.useEffect(() => {
413
+ setFromText(formatDateInput(resolved.from))
414
+ setToText(formatDateInput(resolved.to))
415
+ }, [resolved.from, resolved.to])
416
+
417
+ const commit = () => {
418
+ const r = readTyped(fromText, toText)
419
+ if ("bad" in r) return setTypingError(formatHelp(`${r.bad} date`))
420
+ setTypingError(checkRange(r.from, r.to, rules, maxDays))
421
+ setFromText(formatDateInput(r.from))
422
+ setToText(formatDateInput(r.to))
423
+ if (r.from !== (resolved.from ?? null) || r.to !== (resolved.to ?? null) || value.preset) onValueChange({ from: r.from, to: r.to })
424
+ }
425
+ const shown = typingError ?? error
426
+ const msgId = `${fid}-msg`
427
+ const labelId = `${fid}-label`
428
+ const keys = (e: React.KeyboardEvent<HTMLInputElement>) => {
429
+ if (e.key === "Enter") commit()
430
+ if (e.key === "ArrowDown" && e.altKey) {
431
+ e.preventDefault()
432
+ commit()
433
+ setOpen(true)
434
+ }
435
+ }
436
+ const inputClass = "min-w-0 flex-1 tabular"
437
+
438
+ return (
439
+ <Field data-invalid={!!shown || undefined} className={cn("gap-2", className)}>
440
+ <Label id={labelId} htmlFor={`${fid}-from`} label={label} required={required} optional={optional} />
441
+ <Popover
442
+ open={open}
443
+ onOpenChange={(o) => {
444
+ setOpen(o)
445
+ if (!o) {
446
+ setFromText(formatDateInput(resolved.from))
447
+ setToText(formatDateInput(resolved.to))
448
+ }
449
+ }}
450
+ >
451
+ <PopoverAnchor asChild>
452
+ <InputGroup data-disabled={disabled || undefined} aria-labelledby={labelId}>
453
+ <InputGroupInput ref={fromRef} id={`${fid}-from`} aria-label={`${label}, from`} inputMode="numeric" autoComplete="off" placeholder="dd/mm/yyyy" value={fromText} disabled={disabled} required={required} aria-invalid={shown ? true : undefined} aria-describedby={msgId} onChange={(e) => setFromText(e.target.value)} onBlur={commit} onKeyDown={keys} className={inputClass} />
454
+ <InputGroupText aria-hidden="true" className="px-1 text-ink-3">to</InputGroupText>
455
+ <InputGroupInput id={`${fid}-to`} aria-label={`${label}, to`} inputMode="numeric" autoComplete="off" placeholder="dd/mm/yyyy" value={toText} disabled={disabled} required={required} aria-invalid={shown ? true : undefined} aria-describedby={msgId} onChange={(e) => setToText(e.target.value)} onBlur={commit} onKeyDown={keys} className={inputClass} />
456
+ <InputGroupAddon align="inline-end">
457
+ <InputGroupButton size="icon-xs" aria-label="Choose dates" aria-haspopup="dialog" aria-expanded={open} disabled={disabled} onClick={() => setOpen((o) => !o)}>
458
+ <CalendarDays />
459
+ </InputGroupButton>
460
+ </InputGroupAddon>
461
+ </InputGroup>
462
+ </PopoverAnchor>
463
+ <PopoverContent
464
+ align="end"
465
+ className="w-auto max-w-(--radix-popover-content-available-width) p-0"
466
+ role="dialog"
467
+ aria-label="Choose dates"
468
+ onOpenAutoFocus={(e) => e.preventDefault()}
469
+ onCloseAutoFocus={(e) => {
470
+ e.preventDefault()
471
+ fromRef.current?.focus()
472
+ }}
473
+ >
474
+ <RangeBody
475
+ value={value}
476
+ presets={list}
477
+ maxDays={maxDays}
478
+ rules={rules}
479
+ onStart={(iso) => {
480
+ setFromText(formatDateInput(iso))
481
+ setToText("")
482
+ }}
483
+ onRange={(from, to) => {
484
+ setTypingError(checkRange(from, to, rules, maxDays))
485
+ onValueChange({ from, to })
486
+ setOpen(false)
487
+ }}
488
+ onPreset={(p) => {
489
+ const r = p.range(new Date())
490
+ setTypingError(checkRange(r.from, r.to, rules, maxDays))
491
+ onValueChange({ ...r, preset: p.id })
492
+ setOpen(false)
493
+ }}
494
+ />
495
+ </PopoverContent>
496
+ </Popover>
497
+ <Message id={msgId} error={shown} hint={preset ? `${preset.label}, worked out from today each time.` : hint} />
498
+ </Field>
499
+ )
500
+ }
501
+
502
+ /* ---------------------------------------------------------------- DateFilterPill */
503
+
504
+ const shortDate = (iso: string, withYear: boolean) => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", ...(withYear && { year: "numeric" }) }).format(fromIsoDate(iso))
505
+ /** The operator and value a date filter reads as: "is in" "Last 30 days", "is between" "3 Oct – 12 Oct 2026", "is on or after" "3 Oct 2026". */
506
+ export function describeDateFilter(value: DateRangeValue, presets: DatePreset[] = DATE_PRESETS): { operator: string; text: string; spoken: string } | null {
507
+ const p = value.preset ? presets.find((x) => x.id === value.preset) : undefined
508
+ if (p) return { operator: "is in", text: p.label, spoken: p.label }
509
+ const { from, to } = value
510
+ if (from && to) {
511
+ const sameYear = from.slice(0, 4) === to.slice(0, 4)
512
+ return { operator: "is between", text: `${shortDate(from, !sameYear)} – ${shortDate(to, true)}`, spoken: `${formatDateLong(from)} and ${formatDateLong(to)}` }
513
+ }
514
+ if (from) return { operator: "is on or after", text: shortDate(from, true), spoken: formatDateLong(from) }
515
+ if (to) return { operator: "is on or before", text: shortDate(to, true), spoken: formatDateLong(to) }
516
+ return null
517
+ }
518
+ /**
519
+ * A date filter as URL-safe strings, for FilterValue records and the address bar: ["last-30-days"] for a preset,
520
+ * ["2026-10-01", "2026-10-31"] for dates, with "" for an open end.
521
+ */
522
+ export const dateFilterToValues = (value: DateRangeValue): string[] =>
523
+ value.preset ? [value.preset] : value.from || value.to ? [value.from ?? "", value.to ?? ""] : []
524
+ /** Reads dateFilterToValues back. Anything that isn't a known preset or an ISO date is dropped, so a hand-edited URL can't break the page. */
525
+ export function dateFilterFromValues(values: string[] | undefined, presets: DatePreset[] = DATE_PRESETS): DateRangeValue {
526
+ if (!values?.length) return {}
527
+ if (values.length === 1 && presets.some((p) => p.id === values[0])) return { preset: values[0] }
528
+ const ok = (s?: string) => (s && /^\d{4}-\d{2}-\d{2}$/.test(s) && parseDate(s) ? s : null)
529
+ return { from: ok(values[0]), to: ok(values[1]) }
530
+ }
531
+
532
+ type DateFilterPillProps = Unavailable & {
533
+ /** The field filtered, as on the chip: Applied, Closing date. */
534
+ field: string
535
+ value: DateRangeValue
536
+ /** Called when a preset or a range is chosen, or typed dates are applied with Enter. */
537
+ onChange: (value: DateRangeValue) => void
538
+ /** Removes the pill. Without it, a set pill clears its dates instead. */
539
+ onRemove?: () => void
540
+ /** Preset periods. Defaults to DATE_PRESETS; [] shows the calendar only. */
541
+ presets?: DatePreset[]
542
+ maxDays?: number
543
+ defaultOpen?: boolean
544
+ }
545
+
546
+ /**
547
+ * A filter pill for a date field, in a FilterBar beside FilterPill. Reads as a sentence, "Applied is in Last 30 days";
548
+ * dashed while empty. Presets and calendar ranges apply at once; typed dates apply on Enter.
549
+ */
550
+ export function DateFilterPill({ field, value, onChange, onRemove, presets = DATE_PRESETS, maxDays, defaultOpen = false, ...rules }: DateFilterPillProps) {
551
+ const [open, setOpen] = React.useState(defaultOpen)
552
+ const d = describeDateFilter(value, presets)
553
+ const remove = onRemove ?? (() => onChange({}))
554
+ const segment = "flex items-center px-2 whitespace-nowrap"
555
+ const focus = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
556
+
557
+ return (
558
+ <Popover open={open} onOpenChange={setOpen}>
559
+ {/* The same segmented chip as FilterPill: field | operator | value | remove. */}
560
+ <div
561
+ data-slot="filter-pill"
562
+ data-state={d ? "set" : "empty"}
563
+ className={cn(
564
+ "inline-flex h-7 items-stretch overflow-hidden rounded-sm border text-base transition-colors duration-(--duration-fast) ease-(--ease-out)",
565
+ d ? "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"
566
+ )}
567
+ >
568
+ {d ? (
569
+ <>
570
+ <span className={cn(segment, "text-ink-2")}>{field}</span>
571
+ <span className={cn(segment, "text-ink-3")}>{d.operator}</span>
572
+ <PopoverTrigger asChild>
573
+ <button
574
+ type="button"
575
+ aria-label={`${field} ${d.operator} ${d.spoken}. Change dates`}
576
+ onKeyDown={(e) => { if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault(); remove() } }}
577
+ className={cn(segment, "tabular max-w-56 font-medium transition-colors hover:bg-hover", focus)}
578
+ >
579
+ <span className="truncate">{d.text}</span>
580
+ </button>
581
+ </PopoverTrigger>
582
+ <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)}>
583
+ <X className="size-3.5" aria-hidden="true" />
584
+ </button>
585
+ </>
586
+ ) : (
587
+ <PopoverTrigger asChild>
588
+ <button type="button" className={cn("inline-flex items-center gap-1 px-2 whitespace-nowrap", focus)}>
589
+ <Plus className="size-3.5 text-ink-3" aria-hidden="true" />
590
+ {field}
591
+ </button>
592
+ </PopoverTrigger>
593
+ )}
594
+ </div>
595
+ <PopoverContent align="start" role="dialog" aria-label={`${field} dates`} className="w-auto max-w-(--radix-popover-content-available-width) p-0" onOpenAutoFocus={(e) => e.preventDefault()}>
596
+ <DateRangeChooser
597
+ value={value}
598
+ presets={presets}
599
+ maxDays={maxDays}
600
+ {...rules}
601
+ onApply={(next) => {
602
+ onChange(next)
603
+ setOpen(false)
604
+ }}
605
+ />
606
+ </PopoverContent>
607
+ </Popover>
608
+ )
609
+ }
610
+
611
+ type DateRangeChooserProps = Unavailable & {
612
+ value: DateRangeValue
613
+ /** Called with a preset, a calendar range or typed dates, once they pass the checks. */
614
+ onApply: (value: DateRangeValue) => void
615
+ presets?: DatePreset[]
616
+ maxDays?: number
617
+ /** Months shown side by side from 768px. One beside a menu; two in a pill. */
618
+ months?: 1 | 2
619
+ }
620
+
621
+ /**
622
+ * The body of a date filter: From and To (applied on Enter or Apply), preset periods and a range calendar.
623
+ * Used inside DateFilterPill and beside FilterButton's field list; put it in a popover, never on the page.
624
+ */
625
+ export function DateRangeChooser({ value, onApply, presets = DATE_PRESETS, maxDays, months = 2, ...rules }: DateRangeChooserProps) {
626
+ const auto = React.useId()
627
+ const resolved = resolveDateRange(value, presets.length ? presets : DATE_PRESETS)
628
+ const [fromText, setFromText] = React.useState(formatDateInput(resolved.from))
629
+ const [toText, setToText] = React.useState(formatDateInput(resolved.to))
630
+ const [error, setError] = React.useState<string>()
631
+ const apply = (next: DateRangeValue) => {
632
+ const e = checkRange(next.from, next.to, rules, maxDays)
633
+ if (e) return setError(e)
634
+ onApply(next)
635
+ }
636
+ const applyTyped = () => {
637
+ const r = readTyped(fromText, toText)
638
+ if ("bad" in r) return setError(formatHelp(`${r.bad} date`))
639
+ if (!r.from && !r.to) return setError("Enter a start date, an end date or both.")
640
+ apply({ from: r.from, to: r.to })
641
+ }
642
+ const errId = `${auto}-err`
643
+ return (
644
+ <div data-slot="date-range-chooser">
645
+ <form
646
+ noValidate
647
+ onSubmit={(e) => {
648
+ e.preventDefault()
649
+ applyTyped()
650
+ }}
651
+ className="grid gap-2 border-b border-line p-2"
652
+ >
653
+ <div className="flex items-end gap-2">
654
+ <label className="grid min-w-0 flex-1 gap-1 text-sm text-ink-2">
655
+ From
656
+ <Input value={fromText} onChange={(e) => setFromText(e.target.value)} placeholder="dd/mm/yyyy" inputMode="numeric" autoComplete="off" aria-invalid={error ? true : undefined} aria-describedby={error ? errId : undefined} className="tabular" />
657
+ </label>
658
+ <label className="grid min-w-0 flex-1 gap-1 text-sm text-ink-2">
659
+ To
660
+ <Input value={toText} onChange={(e) => setToText(e.target.value)} placeholder="dd/mm/yyyy" inputMode="numeric" autoComplete="off" aria-invalid={error ? true : undefined} aria-describedby={error ? errId : undefined} className="tabular" />
661
+ </label>
662
+ <Button type="submit" variant="outline">Apply</Button>
663
+ </div>
664
+ {error && (
665
+ <p id={errId} className="flex items-start gap-control text-sm text-bad">
666
+ <AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
667
+ {error}
668
+ </p>
669
+ )}
670
+ </form>
671
+ <RangeBody
672
+ value={value}
673
+ presets={presets}
674
+ maxDays={maxDays}
675
+ months={months}
676
+ rules={rules}
677
+ onStart={(iso) => {
678
+ setFromText(formatDateInput(iso))
679
+ setToText("")
680
+ }}
681
+ onRange={(from, to) => apply({ from, to })}
682
+ onPreset={(p) => apply({ preset: p.id })}
683
+ />
684
+ </div>
685
+ )
686
+ }