@modern-admin/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/dist/components/accordion.d.ts +7 -0
  2. package/dist/components/accordion.d.ts.map +1 -0
  3. package/dist/components/accordion.jsx +19 -0
  4. package/dist/components/accordion.jsx.map +1 -0
  5. package/dist/components/alert-dialog.d.ts +22 -0
  6. package/dist/components/alert-dialog.d.ts.map +1 -0
  7. package/dist/components/alert-dialog.jsx +27 -0
  8. package/dist/components/alert-dialog.jsx.map +1 -0
  9. package/dist/components/audit-timeline.d.ts +24 -0
  10. package/dist/components/audit-timeline.d.ts.map +1 -0
  11. package/dist/components/audit-timeline.jsx +60 -0
  12. package/dist/components/audit-timeline.jsx.map +1 -0
  13. package/dist/components/avatar.d.ts +6 -0
  14. package/dist/components/avatar.d.ts.map +1 -0
  15. package/dist/components/avatar.jsx +10 -0
  16. package/dist/components/avatar.jsx.map +1 -0
  17. package/dist/components/badge.d.ts +10 -0
  18. package/dist/components/badge.d.ts.map +1 -0
  19. package/dist/components/badge.jsx +19 -0
  20. package/dist/components/badge.jsx.map +1 -0
  21. package/dist/components/breadcrumb.d.ts +17 -0
  22. package/dist/components/breadcrumb.d.ts.map +1 -0
  23. package/dist/components/breadcrumb.jsx +27 -0
  24. package/dist/components/breadcrumb.jsx.map +1 -0
  25. package/dist/components/button.d.ts +12 -0
  26. package/dist/components/button.d.ts.map +1 -0
  27. package/dist/components/button.jsx +37 -0
  28. package/dist/components/button.jsx.map +1 -0
  29. package/dist/components/calendar.d.ts +9 -0
  30. package/dist/components/calendar.d.ts.map +1 -0
  31. package/dist/components/calendar.jsx +102 -0
  32. package/dist/components/calendar.jsx.map +1 -0
  33. package/dist/components/card.d.ts +8 -0
  34. package/dist/components/card.d.ts.map +1 -0
  35. package/dist/components/card.jsx +18 -0
  36. package/dist/components/card.jsx.map +1 -0
  37. package/dist/components/chart.d.ts +97 -0
  38. package/dist/components/chart.d.ts.map +1 -0
  39. package/dist/components/chart.jsx +233 -0
  40. package/dist/components/chart.jsx.map +1 -0
  41. package/dist/components/checkbox.d.ts +4 -0
  42. package/dist/components/checkbox.d.ts.map +1 -0
  43. package/dist/components/checkbox.jsx +11 -0
  44. package/dist/components/checkbox.jsx.map +1 -0
  45. package/dist/components/combobox.d.ts +46 -0
  46. package/dist/components/combobox.d.ts.map +1 -0
  47. package/dist/components/combobox.jsx +145 -0
  48. package/dist/components/combobox.jsx.map +1 -0
  49. package/dist/components/command.d.ts +80 -0
  50. package/dist/components/command.d.ts.map +1 -0
  51. package/dist/components/command.jsx +32 -0
  52. package/dist/components/command.jsx.map +1 -0
  53. package/dist/components/date-picker.d.ts +24 -0
  54. package/dist/components/date-picker.d.ts.map +1 -0
  55. package/dist/components/date-picker.jsx +149 -0
  56. package/dist/components/date-picker.jsx.map +1 -0
  57. package/dist/components/date-range-input.d.ts +22 -0
  58. package/dist/components/date-range-input.d.ts.map +1 -0
  59. package/dist/components/date-range-input.jsx +202 -0
  60. package/dist/components/date-range-input.jsx.map +1 -0
  61. package/dist/components/dialog.d.ts +19 -0
  62. package/dist/components/dialog.d.ts.map +1 -0
  63. package/dist/components/dialog.jsx +30 -0
  64. package/dist/components/dialog.jsx.map +1 -0
  65. package/dist/components/diff-view.d.ts +24 -0
  66. package/dist/components/diff-view.d.ts.map +1 -0
  67. package/dist/components/diff-view.jsx +69 -0
  68. package/dist/components/diff-view.jsx.map +1 -0
  69. package/dist/components/dropdown-menu.d.ts +27 -0
  70. package/dist/components/dropdown-menu.d.ts.map +1 -0
  71. package/dist/components/dropdown-menu.jsx +48 -0
  72. package/dist/components/dropdown-menu.jsx.map +1 -0
  73. package/dist/components/empty.d.ts +15 -0
  74. package/dist/components/empty.d.ts.map +1 -0
  75. package/dist/components/empty.jsx +27 -0
  76. package/dist/components/empty.jsx.map +1 -0
  77. package/dist/components/field.d.ts +23 -0
  78. package/dist/components/field.d.ts.map +1 -0
  79. package/dist/components/field.jsx +60 -0
  80. package/dist/components/field.jsx.map +1 -0
  81. package/dist/components/file-input.d.ts +50 -0
  82. package/dist/components/file-input.d.ts.map +1 -0
  83. package/dist/components/file-input.jsx +104 -0
  84. package/dist/components/file-input.jsx.map +1 -0
  85. package/dist/components/form.d.ts +20 -0
  86. package/dist/components/form.d.ts.map +1 -0
  87. package/dist/components/form.jsx +66 -0
  88. package/dist/components/form.jsx.map +1 -0
  89. package/dist/components/info-tooltip.d.ts +11 -0
  90. package/dist/components/info-tooltip.d.ts.map +1 -0
  91. package/dist/components/info-tooltip.jsx +17 -0
  92. package/dist/components/info-tooltip.jsx.map +1 -0
  93. package/dist/components/input.d.ts +13 -0
  94. package/dist/components/input.d.ts.map +1 -0
  95. package/dist/components/input.jsx +19 -0
  96. package/dist/components/input.jsx.map +1 -0
  97. package/dist/components/json-editor.d.ts +23 -0
  98. package/dist/components/json-editor.d.ts.map +1 -0
  99. package/dist/components/json-editor.jsx +143 -0
  100. package/dist/components/json-editor.jsx.map +1 -0
  101. package/dist/components/kbd.d.ts +15 -0
  102. package/dist/components/kbd.d.ts.map +1 -0
  103. package/dist/components/kbd.jsx +23 -0
  104. package/dist/components/kbd.jsx.map +1 -0
  105. package/dist/components/key-value-editor.d.ts +92 -0
  106. package/dist/components/key-value-editor.d.ts.map +1 -0
  107. package/dist/components/key-value-editor.jsx +187 -0
  108. package/dist/components/key-value-editor.jsx.map +1 -0
  109. package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
  110. package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
  111. package/dist/components/keyboard-shortcuts-help.jsx +97 -0
  112. package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
  113. package/dist/components/label.d.ts +5 -0
  114. package/dist/components/label.d.ts.map +1 -0
  115. package/dist/components/label.jsx +8 -0
  116. package/dist/components/label.jsx.map +1 -0
  117. package/dist/components/media-preview.d.ts +30 -0
  118. package/dist/components/media-preview.d.ts.map +1 -0
  119. package/dist/components/media-preview.jsx +189 -0
  120. package/dist/components/media-preview.jsx.map +1 -0
  121. package/dist/components/multi-file-input.d.ts +76 -0
  122. package/dist/components/multi-file-input.d.ts.map +1 -0
  123. package/dist/components/multi-file-input.jsx +131 -0
  124. package/dist/components/multi-file-input.jsx.map +1 -0
  125. package/dist/components/password-input.d.ts +10 -0
  126. package/dist/components/password-input.d.ts.map +1 -0
  127. package/dist/components/password-input.jsx +18 -0
  128. package/dist/components/password-input.jsx.map +1 -0
  129. package/dist/components/popover.d.ts +7 -0
  130. package/dist/components/popover.d.ts.map +1 -0
  131. package/dist/components/popover.jsx +11 -0
  132. package/dist/components/popover.jsx.map +1 -0
  133. package/dist/components/revision-timeline.d.ts +30 -0
  134. package/dist/components/revision-timeline.d.ts.map +1 -0
  135. package/dist/components/revision-timeline.jsx +42 -0
  136. package/dist/components/revision-timeline.jsx.map +1 -0
  137. package/dist/components/richtext-editor.d.ts +43 -0
  138. package/dist/components/richtext-editor.d.ts.map +1 -0
  139. package/dist/components/richtext-editor.jsx +319 -0
  140. package/dist/components/richtext-editor.jsx.map +1 -0
  141. package/dist/components/richtext-mode.d.ts +23 -0
  142. package/dist/components/richtext-mode.d.ts.map +1 -0
  143. package/dist/components/richtext-mode.js +36 -0
  144. package/dist/components/richtext-mode.js.map +1 -0
  145. package/dist/components/richtext-render.d.ts +8 -0
  146. package/dist/components/richtext-render.d.ts.map +1 -0
  147. package/dist/components/richtext-render.jsx +33 -0
  148. package/dist/components/richtext-render.jsx.map +1 -0
  149. package/dist/components/richtext-sync.d.ts +37 -0
  150. package/dist/components/richtext-sync.d.ts.map +1 -0
  151. package/dist/components/richtext-sync.js +46 -0
  152. package/dist/components/richtext-sync.js.map +1 -0
  153. package/dist/components/scroll-area.d.ts +5 -0
  154. package/dist/components/scroll-area.d.ts.map +1 -0
  155. package/dist/components/scroll-area.jsx +16 -0
  156. package/dist/components/scroll-area.jsx.map +1 -0
  157. package/dist/components/select.d.ts +36 -0
  158. package/dist/components/select.d.ts.map +1 -0
  159. package/dist/components/select.jsx +87 -0
  160. package/dist/components/select.jsx.map +1 -0
  161. package/dist/components/separator.d.ts +4 -0
  162. package/dist/components/separator.d.ts.map +1 -0
  163. package/dist/components/separator.jsx +6 -0
  164. package/dist/components/separator.jsx.map +1 -0
  165. package/dist/components/sheet.d.ts +29 -0
  166. package/dist/components/sheet.d.ts.map +1 -0
  167. package/dist/components/sheet.jsx +44 -0
  168. package/dist/components/sheet.jsx.map +1 -0
  169. package/dist/components/sidebar.d.ts +70 -0
  170. package/dist/components/sidebar.d.ts.map +1 -0
  171. package/dist/components/sidebar.jsx +245 -0
  172. package/dist/components/sidebar.jsx.map +1 -0
  173. package/dist/components/skeleton.d.ts +3 -0
  174. package/dist/components/skeleton.d.ts.map +1 -0
  175. package/dist/components/skeleton.jsx +6 -0
  176. package/dist/components/skeleton.jsx.map +1 -0
  177. package/dist/components/sonner.d.ts +6 -0
  178. package/dist/components/sonner.d.ts.map +1 -0
  179. package/dist/components/sonner.jsx +29 -0
  180. package/dist/components/sonner.jsx.map +1 -0
  181. package/dist/components/switch.d.ts +4 -0
  182. package/dist/components/switch.d.ts.map +1 -0
  183. package/dist/components/switch.jsx +8 -0
  184. package/dist/components/switch.jsx.map +1 -0
  185. package/dist/components/table.d.ts +10 -0
  186. package/dist/components/table.d.ts.map +1 -0
  187. package/dist/components/table.jsx +21 -0
  188. package/dist/components/table.jsx.map +1 -0
  189. package/dist/components/tabs.d.ts +7 -0
  190. package/dist/components/tabs.d.ts.map +1 -0
  191. package/dist/components/tabs.jsx +14 -0
  192. package/dist/components/tabs.jsx.map +1 -0
  193. package/dist/components/textarea.d.ts +4 -0
  194. package/dist/components/textarea.d.ts.map +1 -0
  195. package/dist/components/textarea.jsx +5 -0
  196. package/dist/components/textarea.jsx.map +1 -0
  197. package/dist/components/tooltip.d.ts +7 -0
  198. package/dist/components/tooltip.d.ts.map +1 -0
  199. package/dist/components/tooltip.jsx +11 -0
  200. package/dist/components/tooltip.jsx.map +1 -0
  201. package/dist/index.d.ts +52 -0
  202. package/dist/index.d.ts.map +1 -0
  203. package/dist/index.js +72 -0
  204. package/dist/index.js.map +1 -0
  205. package/dist/lib/theme.d.ts +11 -0
  206. package/dist/lib/theme.d.ts.map +1 -0
  207. package/dist/lib/theme.js +44 -0
  208. package/dist/lib/theme.js.map +1 -0
  209. package/dist/lib/utils.d.ts +3 -0
  210. package/dist/lib/utils.d.ts.map +1 -0
  211. package/dist/lib/utils.js +6 -0
  212. package/dist/lib/utils.js.map +1 -0
  213. package/dist/styles.css +242 -0
  214. package/package.json +85 -0
  215. package/src/components/accordion.tsx +48 -0
  216. package/src/components/alert-dialog.tsx +113 -0
  217. package/src/components/audit-timeline.tsx +102 -0
  218. package/src/components/avatar.tsx +42 -0
  219. package/src/components/badge.tsx +34 -0
  220. package/src/components/breadcrumb.tsx +99 -0
  221. package/src/components/button.tsx +58 -0
  222. package/src/components/calendar.tsx +176 -0
  223. package/src/components/card.tsx +60 -0
  224. package/src/components/chart.tsx +558 -0
  225. package/src/components/checkbox.tsx +23 -0
  226. package/src/components/combobox.tsx +264 -0
  227. package/src/components/command.tsx +120 -0
  228. package/src/components/date-picker.tsx +221 -0
  229. package/src/components/date-range-input.tsx +295 -0
  230. package/src/components/dialog.tsx +94 -0
  231. package/src/components/diff-view.tsx +182 -0
  232. package/src/components/dropdown-menu.tsx +165 -0
  233. package/src/components/empty.tsx +100 -0
  234. package/src/components/field.tsx +168 -0
  235. package/src/components/file-input.tsx +233 -0
  236. package/src/components/form.tsx +152 -0
  237. package/src/components/info-tooltip.tsx +40 -0
  238. package/src/components/input.tsx +55 -0
  239. package/src/components/json-editor.tsx +210 -0
  240. package/src/components/kbd.tsx +35 -0
  241. package/src/components/key-value-editor.tsx +423 -0
  242. package/src/components/keyboard-shortcuts-help.tsx +136 -0
  243. package/src/components/label.tsx +16 -0
  244. package/src/components/media-preview.tsx +278 -0
  245. package/src/components/multi-file-input.tsx +315 -0
  246. package/src/components/password-input.tsx +50 -0
  247. package/src/components/popover.tsx +26 -0
  248. package/src/components/revision-timeline.tsx +93 -0
  249. package/src/components/richtext-editor.tsx +624 -0
  250. package/src/components/richtext-mode.ts +39 -0
  251. package/src/components/richtext-render.tsx +51 -0
  252. package/src/components/richtext-sync.ts +57 -0
  253. package/src/components/scroll-area.tsx +41 -0
  254. package/src/components/select.tsx +200 -0
  255. package/src/components/separator.tsx +21 -0
  256. package/src/components/sheet.tsx +109 -0
  257. package/src/components/sidebar.tsx +660 -0
  258. package/src/components/skeleton.tsx +9 -0
  259. package/src/components/sonner.tsx +45 -0
  260. package/src/components/switch.tsx +24 -0
  261. package/src/components/table.tsx +93 -0
  262. package/src/components/tabs.tsx +57 -0
  263. package/src/components/textarea.tsx +18 -0
  264. package/src/components/tooltip.tsx +25 -0
  265. package/src/index.ts +342 -0
  266. package/src/lib/theme.ts +45 -0
  267. package/src/lib/utils.ts +6 -0
  268. package/src/styles.css +242 -0
@@ -0,0 +1,264 @@
1
+ // Combobox — free-text autocomplete input.
2
+ //
3
+ // Distinct from `ReferenceCombobox` (in @modern-admin/react) which strictly
4
+ // constrains the value to an existing referenced record. This primitive is
5
+ // permissive: the user may type any string, and the suggestion list is
6
+ // purely advisory. Suggestions can be:
7
+ // • static (declared per field, e.g. enum-like hints), or
8
+ // • dynamic (loaded by the parent — e.g. distinct values pulled from a
9
+ // resource's column). The component itself is i18n-unaware and does
10
+ // no fetching: callers feed `suggestions` and toggle `loading`.
11
+ //
12
+ // Behaviour:
13
+ // • The input is fully controlled (`value` / `onChange`).
14
+ // • Suggestions filter as you type: case-insensitive substring match
15
+ // against label and value.
16
+ // • Down/Up cycle highlight; Enter commits the highlighted item or
17
+ // keeps the typed value if none is highlighted.
18
+ // • Escape closes the panel; click-outside closes it (handled by Radix
19
+ // Popover). Selecting an item sets the input value and closes.
20
+ //
21
+ // Mobile-first: the panel matches the input width via the
22
+ // `--radix-popover-trigger-width` CSS var.
23
+ //
24
+ // i18n: optional `labels` prop with English defaults. The React layer is
25
+ // expected to translate and feed them in.
26
+
27
+ import * as React from 'react'
28
+ import { ChevronDown, Loader2 } from 'lucide-react'
29
+ import { cn } from '../lib/utils.js'
30
+ import { Input } from './input.js'
31
+ import {
32
+ Popover,
33
+ PopoverAnchor,
34
+ PopoverContent,
35
+ } from './popover.js'
36
+
37
+ /** Each suggestion may be a bare string (used as both value + label) or
38
+ * an explicit `{ value, label }` pair. */
39
+ export type ComboboxSuggestion = string | { value: string; label: string }
40
+
41
+ export interface ComboboxLabels {
42
+ /** Shown while `loading` is true and the panel is open. Default: 'Loading…'. */
43
+ loading?: string
44
+ /**
45
+ * Shown when the panel is open, the user has typed something, but no
46
+ * suggestion matches. Default: 'No matches — press Enter to keep what
47
+ * you typed.' Use `{value}` to interpolate the current input value.
48
+ */
49
+ noMatches?: string
50
+ /** Visually-hidden trigger label for screen readers. Default: 'Toggle suggestions'. */
51
+ toggleSuggestions?: string
52
+ }
53
+
54
+ export interface ComboboxProps {
55
+ /** Current input value. */
56
+ value: string
57
+ /** Called on every keystroke and on suggestion pick. */
58
+ onChange(next: string): void
59
+ /** Called when the input loses focus (after the picker closes). */
60
+ onBlur?(): void
61
+ /** Static or pre-loaded suggestion list. May change while `loading`. */
62
+ suggestions?: ReadonlyArray<ComboboxSuggestion>
63
+ /** When true and the panel is open, render a small spinner. */
64
+ loading?: boolean
65
+ /** Disables the input and prevents the panel from opening. */
66
+ disabled?: boolean
67
+ placeholder?: string
68
+ /** Forwarded to the `<input>` for accessibility (paired with a `<label>`). */
69
+ id?: string
70
+ 'aria-label'?: string
71
+ className?: string
72
+ /**
73
+ * Maximum suggestions to render after filtering. Default: 50. Keeps the
74
+ * panel snappy when callers pass thousands of distinct values.
75
+ */
76
+ maxItems?: number
77
+ labels?: ComboboxLabels
78
+ }
79
+
80
+ const defaultLabels: Required<ComboboxLabels> = {
81
+ loading: 'Loading…',
82
+ noMatches: 'No matches — press Enter to keep "{value}".',
83
+ toggleSuggestions: 'Toggle suggestions',
84
+ }
85
+
86
+ /** Normalise a `ComboboxSuggestion` into `{ value, label }`. */
87
+ const normalise = (s: ComboboxSuggestion): { value: string; label: string } =>
88
+ typeof s === 'string' ? { value: s, label: s } : s
89
+
90
+ /** Case-insensitive substring match on label OR value. */
91
+ const matchesQuery = (
92
+ s: { value: string; label: string },
93
+ q: string,
94
+ ): boolean => {
95
+ if (!q) return true
96
+ const needle = q.toLowerCase()
97
+ return (
98
+ s.label.toLowerCase().includes(needle) ||
99
+ s.value.toLowerCase().includes(needle)
100
+ )
101
+ }
102
+
103
+ export function Combobox({
104
+ value,
105
+ onChange,
106
+ onBlur,
107
+ suggestions,
108
+ loading,
109
+ disabled,
110
+ placeholder,
111
+ id,
112
+ 'aria-label': ariaLabel,
113
+ className,
114
+ maxItems = 50,
115
+ labels,
116
+ }: ComboboxProps): React.ReactElement {
117
+ const l = { ...defaultLabels, ...labels }
118
+ const [focused, setFocused] = React.useState(false)
119
+ const [highlight, setHighlight] = React.useState(0)
120
+
121
+ const items = React.useMemo(() => {
122
+ const all = (suggestions ?? []).map(normalise)
123
+ return all.filter((s) => matchesQuery(s, value)).slice(0, maxItems)
124
+ }, [suggestions, value, maxItems])
125
+
126
+ // Reset highlight whenever the filtered set changes — otherwise the
127
+ // index can point past the end after typing a more specific query.
128
+ React.useEffect(() => {
129
+ setHighlight(0)
130
+ }, [items.length])
131
+
132
+ // Open whenever the input is focused AND there's something to show
133
+ // (either matching items or a loading spinner). Empty + non-loading =
134
+ // no panel, so the component degrades to a plain input.
135
+ const open =
136
+ !disabled && focused && (items.length > 0 || Boolean(loading))
137
+
138
+ const commit = (s: { value: string; label: string }): void => {
139
+ onChange(s.value)
140
+ setFocused(false)
141
+ }
142
+
143
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
144
+ if (!open) return
145
+ if (e.key === 'ArrowDown') {
146
+ e.preventDefault()
147
+ setHighlight((h) => Math.min(h + 1, items.length - 1))
148
+ } else if (e.key === 'ArrowUp') {
149
+ e.preventDefault()
150
+ setHighlight((h) => Math.max(h - 1, 0))
151
+ } else if (e.key === 'Enter') {
152
+ const picked = items[highlight]
153
+ if (picked) {
154
+ e.preventDefault()
155
+ commit(picked)
156
+ }
157
+ } else if (e.key === 'Escape') {
158
+ setFocused(false)
159
+ }
160
+ }
161
+
162
+ return (
163
+ <Popover open={open}>
164
+ <PopoverAnchor asChild>
165
+ <div className={cn('relative', className)}>
166
+ <Input
167
+ id={id}
168
+ type="text"
169
+ value={value}
170
+ placeholder={placeholder}
171
+ disabled={disabled}
172
+ aria-label={ariaLabel}
173
+ aria-autocomplete="list"
174
+ aria-expanded={open}
175
+ autoComplete="off"
176
+ className="pr-8"
177
+ onFocus={() => setFocused(true)}
178
+ onBlur={() => {
179
+ // Defer so a click on a suggestion (which fires after blur)
180
+ // can still commit before we close the panel.
181
+ setTimeout(() => {
182
+ setFocused(false)
183
+ onBlur?.()
184
+ }, 120)
185
+ }}
186
+ onChange={(e) => onChange(e.target.value)}
187
+ onKeyDown={handleKeyDown}
188
+ />
189
+ {/* Trailing affordance: spinner while loading, otherwise a chevron
190
+ hint that suggestions exist. Pure decoration — focusing the
191
+ input is what opens the panel. */}
192
+ <span
193
+ aria-hidden="true"
194
+ className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-muted-foreground"
195
+ >
196
+ {loading ? (
197
+ <Loader2 className="size-4 animate-spin" />
198
+ ) : (
199
+ <ChevronDown className="size-4 opacity-50" />
200
+ )}
201
+ </span>
202
+ </div>
203
+ </PopoverAnchor>
204
+ <PopoverContent
205
+ align="start"
206
+ sideOffset={4}
207
+ // Prevent Radix from stealing focus from the input when the panel
208
+ // opens — otherwise typing would move focus to the popover root.
209
+ onOpenAutoFocus={(e) => e.preventDefault()}
210
+ // Allow clicks inside the panel without closing the popover before
211
+ // the click handler on the option fires.
212
+ onInteractOutside={(e) => {
213
+ // The blur handler closes the panel; nothing more to do here.
214
+ // Preventing the default keeps Radix from re-toggling internal state.
215
+ e.preventDefault()
216
+ }}
217
+ className="w-[var(--radix-popover-trigger-width)] p-1"
218
+ >
219
+ {loading && items.length === 0 ? (
220
+ <div className="px-2 py-2 text-sm text-muted-foreground">
221
+ {l.loading}
222
+ </div>
223
+ ) : items.length === 0 ? (
224
+ <div className="px-2 py-2 text-sm text-muted-foreground">
225
+ {l.noMatches.replace('{value}', value)}
226
+ </div>
227
+ ) : (
228
+ <ul role="listbox" className="max-h-60 overflow-y-auto">
229
+ {items.map((s, i) => {
230
+ const active = i === highlight
231
+ return (
232
+ <li
233
+ key={s.value}
234
+ role="option"
235
+ aria-selected={active}
236
+ // Use mousedown (fires before input blur) so the click
237
+ // commits the value before the panel closes.
238
+ onMouseDown={(e) => {
239
+ e.preventDefault()
240
+ commit(s)
241
+ }}
242
+ onMouseEnter={() => setHighlight(i)}
243
+ className={cn(
244
+ 'cursor-pointer rounded-sm px-2 py-1.5 text-sm',
245
+ active
246
+ ? 'bg-accent text-accent-foreground'
247
+ : 'text-foreground',
248
+ )}
249
+ >
250
+ <span className="truncate">{s.label}</span>
251
+ {s.label !== s.value ? (
252
+ <span className="ml-2 text-xs text-muted-foreground">
253
+ {s.value}
254
+ </span>
255
+ ) : null}
256
+ </li>
257
+ )
258
+ })}
259
+ </ul>
260
+ )}
261
+ </PopoverContent>
262
+ </Popover>
263
+ )
264
+ }
@@ -0,0 +1,120 @@
1
+ import * as React from 'react'
2
+ import { Command as CommandPrimitive } from 'cmdk'
3
+ import { Search } from 'lucide-react'
4
+ import { cn } from '../lib/utils.js'
5
+ import { Dialog, DialogContent } from './dialog.js'
6
+
7
+ export const Command = React.forwardRef<
8
+ React.ElementRef<typeof CommandPrimitive>,
9
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
10
+ >(({ className, ...props }, ref) => (
11
+ <CommandPrimitive
12
+ ref={ref}
13
+ className={cn(
14
+ 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
15
+ className,
16
+ )}
17
+ {...props}
18
+ />
19
+ ))
20
+ Command.displayName = CommandPrimitive.displayName
21
+
22
+ export const CommandDialog = ({
23
+ children,
24
+ ...props
25
+ }: React.ComponentProps<typeof Dialog>): React.ReactElement => (
26
+ <Dialog {...props}>
27
+ <DialogContent className="overflow-hidden p-0 shadow-lg">
28
+ <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
29
+ {children}
30
+ </Command>
31
+ </DialogContent>
32
+ </Dialog>
33
+ )
34
+
35
+ export const CommandInput = React.forwardRef<
36
+ React.ElementRef<typeof CommandPrimitive.Input>,
37
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
38
+ >(({ className, ...props }, ref) => (
39
+ <div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
40
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
41
+ <CommandPrimitive.Input
42
+ ref={ref}
43
+ className={cn(
44
+ 'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
45
+ className,
46
+ )}
47
+ {...props}
48
+ />
49
+ </div>
50
+ ))
51
+ CommandInput.displayName = CommandPrimitive.Input.displayName
52
+
53
+ export const CommandList = React.forwardRef<
54
+ React.ElementRef<typeof CommandPrimitive.List>,
55
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
56
+ >(({ className, ...props }, ref) => (
57
+ <CommandPrimitive.List
58
+ ref={ref}
59
+ className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
60
+ {...props}
61
+ />
62
+ ))
63
+ CommandList.displayName = CommandPrimitive.List.displayName
64
+
65
+ export const CommandEmpty = React.forwardRef<
66
+ React.ElementRef<typeof CommandPrimitive.Empty>,
67
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
68
+ >((props, ref) => (
69
+ <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
70
+ ))
71
+ CommandEmpty.displayName = CommandPrimitive.Empty.displayName
72
+
73
+ export const CommandGroup = React.forwardRef<
74
+ React.ElementRef<typeof CommandPrimitive.Group>,
75
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
76
+ >(({ className, ...props }, ref) => (
77
+ <CommandPrimitive.Group
78
+ ref={ref}
79
+ className={cn(
80
+ 'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
81
+ className,
82
+ )}
83
+ {...props}
84
+ />
85
+ ))
86
+ CommandGroup.displayName = CommandPrimitive.Group.displayName
87
+
88
+ export const CommandSeparator = React.forwardRef<
89
+ React.ElementRef<typeof CommandPrimitive.Separator>,
90
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
91
+ >(({ className, ...props }, ref) => (
92
+ <CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border/40', className)} {...props} />
93
+ ))
94
+ CommandSeparator.displayName = CommandPrimitive.Separator.displayName
95
+
96
+ export const CommandItem = React.forwardRef<
97
+ React.ElementRef<typeof CommandPrimitive.Item>,
98
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
99
+ >(({ className, ...props }, ref) => (
100
+ <CommandPrimitive.Item
101
+ ref={ref}
102
+ className={cn(
103
+ 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
104
+ className,
105
+ )}
106
+ {...props}
107
+ />
108
+ ))
109
+ CommandItem.displayName = CommandPrimitive.Item.displayName
110
+
111
+ export const CommandShortcut = ({
112
+ className,
113
+ ...props
114
+ }: React.HTMLAttributes<HTMLSpanElement>): React.ReactElement => (
115
+ <span
116
+ className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
117
+ {...props}
118
+ />
119
+ )
120
+ CommandShortcut.displayName = 'CommandShortcut'
@@ -0,0 +1,221 @@
1
+ // Popover-driven date / datetime input. Single picker handles both modes:
2
+ // pass `mode="datetime"` to surface an HH:MM time input below the calendar.
3
+ //
4
+ // Value is an ISO-ish string (`yyyy-MM-dd` for dates, `yyyy-MM-ddTHH:mm` for
5
+ // datetime — same shape <input type="date">/<input type="datetime-local">
6
+ // produce, so callers can stay format-stable). The trigger is a real text
7
+ // input so users can also type the date manually; clicks on the trailing
8
+ // calendar icon open the popover with the inline picker.
9
+
10
+ import * as React from 'react'
11
+ import { format, isValid, parse, parseISO } from 'date-fns'
12
+ import { Calendar as CalendarIcon } from 'lucide-react'
13
+ import { cn } from '../lib/utils.js'
14
+ import { Button } from './button.js'
15
+ import { Calendar } from './calendar.js'
16
+ import { Input } from './input.js'
17
+ import { Popover, PopoverContent, PopoverTrigger } from './popover.js'
18
+
19
+ export type DatePickerMode = 'date' | 'datetime'
20
+
21
+ export interface DatePickerProps {
22
+ value: string | null | undefined
23
+ onChange(next: string): void
24
+ mode?: DatePickerMode
25
+ disabled?: boolean
26
+ placeholder?: string
27
+ /** Applied to the outer wrapper div (controls width, etc.). */
28
+ className?: string
29
+ /** Applied to the inner text Input — use to override height / font size. */
30
+ inputClassName?: string
31
+ /** ARIA label forwarded to the trigger input (mobile users / screen readers). */
32
+ ariaLabel?: string
33
+ /** ARIA label for the calendar icon button. Default: "Open calendar". */
34
+ openCalendarLabel?: string
35
+ /** Label for the time input shown in datetime mode. Default: "Time". */
36
+ timeLabel?: string
37
+ }
38
+
39
+ const DATE_FMT = 'yyyy-MM-dd'
40
+ const DATETIME_FMT = "yyyy-MM-dd'T'HH:mm"
41
+ // Friendlier display format for the datetime input — space instead of `T`.
42
+ const DATETIME_DISPLAY_FMT = 'yyyy-MM-dd HH:mm'
43
+
44
+ function parseValue(value: string | null | undefined): Date | undefined {
45
+ if (!value) return undefined
46
+ const direct = value.length <= 10 ? parse(value, DATE_FMT, new Date()) : parseISO(value)
47
+ return Number.isNaN(direct.getTime()) ? undefined : direct
48
+ }
49
+
50
+ /**
51
+ * Try to parse a manually-typed string in any of the supported shapes.
52
+ * Returns `undefined` on blank, `null` on invalid (non-blank) input so the
53
+ * caller can distinguish "cleared" from "typo".
54
+ */
55
+ function parseTyped(raw: string, mode: DatePickerMode): Date | undefined | null {
56
+ const trimmed = raw.trim()
57
+ if (trimmed === '') return undefined
58
+ const candidates =
59
+ mode === 'datetime'
60
+ ? [DATETIME_FMT, DATETIME_DISPLAY_FMT, "yyyy-MM-dd'T'HH:mm:ss", DATE_FMT]
61
+ : [DATE_FMT]
62
+ for (const fmt of candidates) {
63
+ const parsed = parse(trimmed, fmt, new Date())
64
+ if (isValid(parsed)) return parsed
65
+ }
66
+ // Last resort: ISO with timezone, etc.
67
+ const iso = parseISO(trimmed)
68
+ return isValid(iso) ? iso : null
69
+ }
70
+
71
+ function formatForInput(date: Date | undefined, mode: DatePickerMode): string {
72
+ if (!date) return ''
73
+ return format(date, mode === 'datetime' ? DATETIME_DISPLAY_FMT : DATE_FMT)
74
+ }
75
+
76
+ function formatForApi(date: Date, mode: DatePickerMode): string {
77
+ return format(date, mode === 'datetime' ? DATETIME_FMT : DATE_FMT)
78
+ }
79
+
80
+ export function DatePicker({
81
+ value,
82
+ onChange,
83
+ mode = 'date',
84
+ disabled,
85
+ placeholder,
86
+ className,
87
+ inputClassName,
88
+ ariaLabel,
89
+ openCalendarLabel = 'Open calendar',
90
+ timeLabel = 'Time',
91
+ }: DatePickerProps): React.ReactElement {
92
+ const [open, setOpen] = React.useState(false)
93
+ const date = parseValue(value)
94
+ // Local draft for the text input — keeps user typing intact even when
95
+ // intermediate strings don't yet parse.
96
+ const [draft, setDraft] = React.useState(() => formatForInput(date, mode))
97
+ // Re-sync draft whenever the canonical value changes from outside (e.g.
98
+ // calendar selection or form reset). Compare via formatted shape so a noop
99
+ // update doesn't clobber what the user is typing.
100
+ const lastFormatted = React.useRef(formatForInput(date, mode))
101
+ React.useEffect(() => {
102
+ const next = formatForInput(date, mode)
103
+ if (next !== lastFormatted.current) {
104
+ lastFormatted.current = next
105
+ setDraft(next)
106
+ }
107
+ }, [value, mode]) // eslint-disable-line react-hooks/exhaustive-deps
108
+
109
+ const time = date ? format(date, 'HH:mm') : '00:00'
110
+
111
+ const commitDate = (next: Date | undefined): void => {
112
+ if (!next) {
113
+ lastFormatted.current = ''
114
+ setDraft('')
115
+ onChange('')
116
+ return
117
+ }
118
+ if (mode === 'datetime') {
119
+ const [h, m] = time.split(':').map(Number)
120
+ next.setHours(h ?? 0, m ?? 0, 0, 0)
121
+ }
122
+ const formatted = formatForInput(next, mode)
123
+ lastFormatted.current = formatted
124
+ setDraft(formatted)
125
+ onChange(formatForApi(next, mode))
126
+ }
127
+
128
+ const setTime = (raw: string): void => {
129
+ if (!date) return
130
+ const [h, m] = raw.split(':').map(Number)
131
+ const next = new Date(date)
132
+ next.setHours(h ?? 0, m ?? 0, 0, 0)
133
+ const formatted = formatForInput(next, mode)
134
+ lastFormatted.current = formatted
135
+ setDraft(formatted)
136
+ onChange(formatForApi(next, mode))
137
+ }
138
+
139
+ const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
140
+ const text = e.target.value
141
+ setDraft(text)
142
+ const parsed = parseTyped(text, mode)
143
+ if (parsed === undefined) {
144
+ // cleared
145
+ lastFormatted.current = ''
146
+ onChange('')
147
+ } else if (parsed) {
148
+ lastFormatted.current = formatForInput(parsed, mode)
149
+ onChange(formatForApi(parsed, mode))
150
+ }
151
+ // parsed === null → keep draft, don't fire onChange yet
152
+ }
153
+
154
+ const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = () => {
155
+ // On blur, if the draft is invalid, snap back to the canonical value.
156
+ const parsed = parseTyped(draft, mode)
157
+ if (parsed === null) {
158
+ const restored = formatForInput(date, mode)
159
+ lastFormatted.current = restored
160
+ setDraft(restored)
161
+ }
162
+ }
163
+
164
+ const inputPlaceholder =
165
+ placeholder ?? (mode === 'datetime' ? 'YYYY-MM-DD HH:MM' : 'YYYY-MM-DD')
166
+
167
+ return (
168
+ <Popover open={open} onOpenChange={setOpen}>
169
+ <div className={cn('relative w-full', className)}>
170
+ <Input
171
+ type="text"
172
+ inputMode="numeric"
173
+ value={draft}
174
+ onChange={handleInputChange}
175
+ onBlur={handleInputBlur}
176
+ placeholder={inputPlaceholder}
177
+ disabled={disabled}
178
+ aria-label={ariaLabel}
179
+ className={cn(inputClassName, 'pr-10')}
180
+ />
181
+ <PopoverTrigger asChild>
182
+ <Button
183
+ type="button"
184
+ variant="ghost"
185
+ size="icon"
186
+ disabled={disabled}
187
+ aria-label={openCalendarLabel}
188
+ className="absolute right-1 top-1/2 size-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
189
+ >
190
+ <CalendarIcon className="size-4" />
191
+ </Button>
192
+ </PopoverTrigger>
193
+ </div>
194
+ <PopoverContent className="w-auto p-0" align="start">
195
+ <Calendar
196
+ mode="single"
197
+ selected={date}
198
+ defaultMonth={date}
199
+ onSelect={(d) => {
200
+ commitDate(d)
201
+ if (mode === 'date') setOpen(false)
202
+ }}
203
+ autoFocus
204
+ />
205
+ {mode === 'datetime' && (
206
+ <div className="flex items-center gap-2 border-t p-3">
207
+ <span className="text-xs text-muted-foreground">{timeLabel}</span>
208
+ <Input
209
+ type="time"
210
+ value={time}
211
+ disabled={!date}
212
+ onChange={(e) => setTime(e.target.value)}
213
+ className="h-8 w-32"
214
+ />
215
+ </div>
216
+ )}
217
+ </PopoverContent>
218
+ </Popover>
219
+ )
220
+ }
221
+ DatePicker.displayName = 'DatePicker'