@thangnqdev/ui 0.2.0 → 0.2.2

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 (190) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +144 -142
  3. package/bin/cli.js +125 -0
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/buttons/index.cjs.map +1 -1
  7. package/dist/buttons/index.js +3 -3
  8. package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
  9. package/dist/chunk-2OXZK6IP.js.map +1 -0
  10. package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
  11. package/dist/chunk-4GNFT2ZJ.js.map +1 -0
  12. package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
  13. package/dist/chunk-4S6L5V7X.js.map +1 -0
  14. package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
  15. package/dist/chunk-66VWUJEI.js.map +1 -0
  16. package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
  17. package/dist/chunk-7NMB2Y64.js.map +1 -0
  18. package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
  19. package/dist/chunk-A7P3O5Z5.js.map +1 -0
  20. package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
  21. package/dist/chunk-ATYGDTMK.js.map +1 -0
  22. package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
  23. package/dist/chunk-C4SUXMI3.js.map +1 -0
  24. package/dist/{chunk-EXFR2BFJ.js → chunk-DUPPPGXY.js} +7 -7
  25. package/dist/chunk-DUPPPGXY.js.map +1 -0
  26. package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
  27. package/dist/chunk-EU2LPUYP.js.map +1 -0
  28. package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
  29. package/dist/chunk-GXI2H5XA.js.map +1 -0
  30. package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
  31. package/dist/chunk-IABPUCAQ.js.map +1 -0
  32. package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
  33. package/dist/chunk-JOKEKOP3.js.map +1 -0
  34. package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
  35. package/dist/chunk-KUQWUTZA.js.map +1 -0
  36. package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
  37. package/dist/chunk-U3J4UPV3.js.map +1 -0
  38. package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
  39. package/dist/chunk-VE2PNSDQ.js.map +1 -0
  40. package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
  41. package/dist/chunk-WAFHC4DQ.js.map +1 -0
  42. package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
  43. package/dist/chunk-YJ67WYVW.js.map +1 -0
  44. package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
  45. package/dist/chunk-Z427NPMU.js.map +1 -0
  46. package/dist/core/index.cjs +4 -4
  47. package/dist/core/index.cjs.map +1 -1
  48. package/dist/core/index.js +2 -2
  49. package/dist/data-display/index.cjs.map +1 -1
  50. package/dist/data-display/index.js +5 -5
  51. package/dist/feedback/index.cjs.map +1 -1
  52. package/dist/feedback/index.js +2 -2
  53. package/dist/forms/index.cjs.map +1 -1
  54. package/dist/forms/index.js +8 -8
  55. package/dist/glass/index.cjs.map +1 -1
  56. package/dist/glass/index.js +2 -2
  57. package/dist/index.cjs +4 -4
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.js +19 -19
  60. package/dist/layout/index.cjs.map +1 -1
  61. package/dist/layout/index.js +2 -2
  62. package/dist/navigation/index.cjs.map +1 -1
  63. package/dist/navigation/index.js +6 -6
  64. package/dist/overlays/index.cjs.map +1 -1
  65. package/dist/overlays/index.js +7 -7
  66. package/dist/reset.css +45 -45
  67. package/dist/tokens.css +11 -11
  68. package/dist/typography/index.cjs.map +1 -1
  69. package/dist/typography/index.js +4 -4
  70. package/llms.txt +3 -3
  71. package/package.json +166 -159
  72. package/scripts/postinstall.js +125 -0
  73. package/src/bento/BentoCard.tsx +140 -140
  74. package/src/bento/BentoGrid.tsx +50 -50
  75. package/src/bento/index.ts +2 -2
  76. package/src/bento/tokens.css +46 -46
  77. package/src/buttons/Button.tsx +104 -104
  78. package/src/buttons/ButtonGroup.tsx +43 -43
  79. package/src/buttons/IconButton.tsx +79 -79
  80. package/src/buttons/SplitButton.tsx +186 -186
  81. package/src/buttons/ToggleButton.tsx +157 -157
  82. package/src/buttons/index.ts +5 -5
  83. package/src/buttons/tokens.css +6 -6
  84. package/src/core/ThemeProvider.tsx +133 -133
  85. package/src/core/index.ts +2 -2
  86. package/src/core/tokens.css +134 -134
  87. package/src/data-display/Accordion.tsx +147 -147
  88. package/src/data-display/Avatar.tsx +134 -134
  89. package/src/data-display/Card.tsx +112 -112
  90. package/src/data-display/Chip.tsx +74 -74
  91. package/src/data-display/DataTablePro.tsx +534 -534
  92. package/src/data-display/StatCard.tsx +60 -60
  93. package/src/data-display/Table.tsx +74 -74
  94. package/src/data-display/Timeline.tsx +62 -62
  95. package/src/data-display/TreeView.tsx +279 -279
  96. package/src/data-display/tokens.css +4 -4
  97. package/src/feedback/Alert.tsx +80 -80
  98. package/src/feedback/EmptyState.tsx +45 -45
  99. package/src/feedback/Skeleton.tsx +66 -66
  100. package/src/feedback/Spinner.tsx +65 -65
  101. package/src/feedback/index.ts +5 -5
  102. package/src/feedback/tokens.css +4 -4
  103. package/src/forms/Checkbox.tsx +149 -149
  104. package/src/forms/CheckboxGroup.tsx +76 -76
  105. package/src/forms/Combobox.tsx +512 -512
  106. package/src/forms/DatePicker.tsx +784 -784
  107. package/src/forms/FileUpload.tsx +266 -266
  108. package/src/forms/FormField.tsx +115 -115
  109. package/src/forms/Input.tsx +151 -151
  110. package/src/forms/NumberInput.tsx +262 -262
  111. package/src/forms/PasswordInput.tsx +35 -35
  112. package/src/forms/PinInput.tsx +139 -139
  113. package/src/forms/Radio.tsx +142 -142
  114. package/src/forms/RangeSlider.tsx +237 -237
  115. package/src/forms/SearchInput.tsx +34 -34
  116. package/src/forms/Select.tsx +103 -103
  117. package/src/forms/Slider.tsx +85 -85
  118. package/src/forms/Textarea.tsx +114 -114
  119. package/src/forms/tokens.css +6 -6
  120. package/src/glass/GlassButton.tsx +59 -59
  121. package/src/glass/GlassCard.tsx +71 -71
  122. package/src/glass/index.ts +3 -3
  123. package/src/glass/tokens.css +81 -81
  124. package/src/index.ts +26 -26
  125. package/src/layout/AspectRatio.tsx +39 -39
  126. package/src/layout/Container.tsx +53 -53
  127. package/src/layout/Divider.tsx +61 -61
  128. package/src/layout/Flex.tsx +79 -79
  129. package/src/layout/Grid.tsx +124 -124
  130. package/src/layout/PageLayout.tsx +59 -59
  131. package/src/layout/Section.tsx +47 -47
  132. package/src/layout/Spacer.tsx +30 -30
  133. package/src/layout/Stack.tsx +88 -88
  134. package/src/layout/tokens.css +8 -8
  135. package/src/navigation/Breadcrumb.tsx +73 -73
  136. package/src/navigation/CommandPalette.tsx +142 -142
  137. package/src/navigation/DropdownMenu.tsx +244 -244
  138. package/src/navigation/Pagination.tsx +114 -114
  139. package/src/navigation/Stepper.tsx +93 -93
  140. package/src/navigation/Tabs.tsx +265 -265
  141. package/src/navigation/index.ts +6 -6
  142. package/src/navigation/tokens.css +4 -4
  143. package/src/overlays/AlertDialog.tsx +63 -63
  144. package/src/overlays/BottomSheet.tsx +143 -143
  145. package/src/overlays/ContextMenu.tsx +161 -161
  146. package/src/overlays/Drawer.tsx +160 -160
  147. package/src/overlays/FocusTrap.tsx +122 -122
  148. package/src/overlays/Modal.tsx +296 -296
  149. package/src/overlays/Popover.tsx +156 -156
  150. package/src/overlays/Portal.tsx +23 -23
  151. package/src/overlays/Toast.tsx +175 -175
  152. package/src/overlays/Tooltip.tsx +125 -125
  153. package/src/overlays/tokens.css +8 -8
  154. package/src/registry/components.ts +1368 -1368
  155. package/src/styles/components.css +39 -39
  156. package/src/styles/index.css +5 -5
  157. package/src/styles/reset.css +45 -45
  158. package/src/styles/tokens.css +11 -11
  159. package/src/typography/Blockquote.tsx +32 -32
  160. package/src/typography/Code.tsx +89 -89
  161. package/src/typography/Heading.tsx +56 -56
  162. package/src/typography/Kbd.tsx +25 -25
  163. package/src/typography/Label.tsx +47 -47
  164. package/src/typography/Link.tsx +56 -56
  165. package/src/typography/Text.tsx +62 -62
  166. package/src/typography/index.ts +7 -7
  167. package/src/typography/tokens.css +5 -5
  168. package/src/utils/cn.ts +6 -6
  169. package/src/utils/scroll-lock.ts +19 -19
  170. package/src/utils/useControllableState.ts +54 -54
  171. package/ui-contract.json +1 -1
  172. package/dist/chunk-2YJBW4E2.js.map +0 -1
  173. package/dist/chunk-4CRFNALF.js.map +0 -1
  174. package/dist/chunk-4MS6ZHFQ.js.map +0 -1
  175. package/dist/chunk-77KTYOMQ.js.map +0 -1
  176. package/dist/chunk-ADO3SVUA.js.map +0 -1
  177. package/dist/chunk-D7FB3TCA.js.map +0 -1
  178. package/dist/chunk-DSEL7ELZ.js.map +0 -1
  179. package/dist/chunk-EXFR2BFJ.js.map +0 -1
  180. package/dist/chunk-GXWB6BSY.js.map +0 -1
  181. package/dist/chunk-I2FQBMVW.js.map +0 -1
  182. package/dist/chunk-I5O2WCGF.js.map +0 -1
  183. package/dist/chunk-ME4XILG5.js.map +0 -1
  184. package/dist/chunk-PC5SZFZC.js.map +0 -1
  185. package/dist/chunk-S6XJMYME.js.map +0 -1
  186. package/dist/chunk-V52JOSDF.js.map +0 -1
  187. package/dist/chunk-W5WNWTQC.js.map +0 -1
  188. package/dist/chunk-YJ64RI7J.js.map +0 -1
  189. package/dist/chunk-YOCGBORV.js.map +0 -1
  190. package/dist/chunk-ZEDXARXW.js.map +0 -1
@@ -1,784 +1,784 @@
1
- import React, { forwardRef, useState, useRef, useEffect } from 'react';
2
- import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight, X } from 'lucide-react';
3
- import { cn } from '../utils/cn';
4
- import { Popover } from '../overlays/Popover';
5
- import { useControllableState } from '../utils/useControllableState';
6
-
7
- // Utility helper to format date as YYYY-MM-DD
8
- export function formatDate(d: Date | null | undefined): string {
9
- if (!d) return '';
10
- const year = d.getFullYear();
11
- const month = String(d.getMonth() + 1).padStart(2, '0');
12
- const day = String(d.getDate()).padStart(2, '0');
13
- return `${year}-${month}-${day}`;
14
- }
15
-
16
- export function formatDisplayDate(d: Date | null | undefined): string {
17
- if (!d) return '';
18
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
19
- }
20
-
21
- export interface DatePickerProps {
22
- value?: Date | null;
23
- defaultValue?: Date | null;
24
- onChange?: (date: Date | null) => void;
25
- placeholder?: string;
26
- minDate?: Date;
27
- maxDate?: Date;
28
- disabled?: boolean;
29
- clearable?: boolean;
30
- className?: string;
31
- presets?: { label: string; date: Date }[];
32
- }
33
-
34
- export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
35
- (
36
- {
37
- value: controlledValue,
38
- defaultValue = null,
39
- onChange,
40
- placeholder = 'Select date...',
41
- minDate,
42
- maxDate,
43
- disabled = false,
44
- clearable = true,
45
- className,
46
- presets,
47
- },
48
- ref
49
- ) => {
50
- const [selectedDate, setSelectedDate] = useControllableState<Date | null>({
51
- value: controlledValue,
52
- defaultValue,
53
- onChange,
54
- name: 'DatePicker',
55
- });
56
-
57
- const [viewDate, setViewDate] = useState<Date>(selectedDate || new Date());
58
- const [isOpen, setIsOpen] = useState(false);
59
- const [focusedDate, setFocusedDate] = useState<Date>(selectedDate || new Date());
60
-
61
- useEffect(() => {
62
- if (selectedDate) {
63
- setViewDate(selectedDate);
64
- setFocusedDate(selectedDate);
65
- }
66
- }, [selectedDate]);
67
-
68
- const handleSelectDate = (d: Date) => {
69
- setSelectedDate(d);
70
- setIsOpen(false);
71
- };
72
-
73
- const handleClear = (e: React.MouseEvent) => {
74
- e.stopPropagation();
75
- setSelectedDate(null);
76
- };
77
-
78
- const prevMonth = () => {
79
- setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
80
- };
81
-
82
- const nextMonth = () => {
83
- setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
84
- };
85
-
86
- // Calendar generation logic
87
- const year = viewDate.getFullYear();
88
- const month = viewDate.getMonth();
89
- const firstDayIndex = new Date(year, month, 1).getDay();
90
- const daysInMonth = new Date(year, month + 1, 0).getDate();
91
- const daysInPrevMonth = new Date(year, month, 0).getDate();
92
-
93
- const days: { date: Date; currentMonth: boolean; disabled: boolean }[] = [];
94
-
95
- // Prev month padding days
96
- for (let i = firstDayIndex - 1; i >= 0; i--) {
97
- const d = new Date(year, month - 1, daysInPrevMonth - i);
98
- const isDis = (minDate && d < minDate) || (maxDate && d > maxDate);
99
- days.push({ date: d, currentMonth: false, disabled: Boolean(isDis) });
100
- }
101
-
102
- // Current month days
103
- for (let i = 1; i <= daysInMonth; i++) {
104
- const d = new Date(year, month, i);
105
- const isDis = (minDate && d < minDate) || (maxDate && d > maxDate);
106
- days.push({ date: d, currentMonth: true, disabled: Boolean(isDis) });
107
- }
108
-
109
- // Next month padding days to complete grid of 35 or 42
110
- const totalSlots = days.length > 35 ? 42 : 35;
111
- const remainingSlots = totalSlots - days.length;
112
- for (let i = 1; i <= remainingSlots; i++) {
113
- const d = new Date(year, month + 1, i);
114
- const isDis = (minDate && d < minDate) || (maxDate && d > maxDate);
115
- days.push({ date: d, currentMonth: false, disabled: Boolean(isDis) });
116
- }
117
-
118
- const todayStr = formatDate(new Date());
119
- const selectedStr = selectedDate ? formatDate(selectedDate) : '';
120
- const focusedStr = formatDate(focusedDate);
121
-
122
- // Keyboard navigation within the calendar grid
123
- const handleGridKeyDown = (e: React.KeyboardEvent) => {
124
- let nextDate = new Date(focusedDate);
125
- let handled = false;
126
-
127
- if (e.key === 'ArrowLeft') {
128
- nextDate.setDate(nextDate.getDate() - 1);
129
- handled = true;
130
- } else if (e.key === 'ArrowRight') {
131
- nextDate.setDate(nextDate.getDate() + 1);
132
- handled = true;
133
- } else if (e.key === 'ArrowUp') {
134
- nextDate.setDate(nextDate.getDate() - 7);
135
- handled = true;
136
- } else if (e.key === 'ArrowDown') {
137
- nextDate.setDate(nextDate.getDate() + 7);
138
- handled = true;
139
- } else if (e.key === 'PageUp') {
140
- nextDate.setMonth(nextDate.getMonth() - 1);
141
- handled = true;
142
- } else if (e.key === 'PageDown') {
143
- nextDate.setMonth(nextDate.getMonth() + 1);
144
- handled = true;
145
- } else if (e.key === 'Home') {
146
- nextDate.setDate(1);
147
- handled = true;
148
- } else if (e.key === 'End') {
149
- const lastDay = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate();
150
- nextDate.setDate(lastDay);
151
- handled = true;
152
- } else if (e.key === 'Enter' || e.key === ' ') {
153
- e.preventDefault();
154
- handleSelectDate(focusedDate);
155
- return;
156
- } else if (e.key === 'Escape') {
157
- e.preventDefault();
158
- setIsOpen(false);
159
- return;
160
- }
161
-
162
- if (handled) {
163
- e.preventDefault();
164
- setFocusedDate(nextDate);
165
- if (nextDate.getMonth() !== viewDate.getMonth() || nextDate.getFullYear() !== viewDate.getFullYear()) {
166
- setViewDate(new Date(nextDate.getFullYear(), nextDate.getMonth(), 1));
167
- }
168
- }
169
- };
170
-
171
- return (
172
- <div ref={ref} className={cn('relative inline-block w-full', className)}>
173
- <Popover
174
- open={isOpen}
175
- onOpenChange={setIsOpen}
176
- trigger={
177
- <button
178
- type="button"
179
- disabled={disabled}
180
- aria-haspopup="dialog"
181
- aria-expanded={isOpen}
182
- aria-label={selectedDate ? `Selected date: ${formatDisplayDate(selectedDate)}` : placeholder}
183
- className={cn(
184
- 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
185
- disabled
186
- ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
187
- : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
188
- )}
189
- >
190
- <div className="flex items-center gap-2.5 truncate">
191
- <CalendarIcon className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
192
- <span className={selectedDate ? 'text-[var(--ui-text-primary)] font-medium' : 'text-[var(--ui-text-muted)]'}>
193
- {selectedDate ? formatDisplayDate(selectedDate) : placeholder}
194
- </span>
195
- </div>
196
-
197
- <div className="flex items-center gap-1 text-[var(--ui-text-muted)]">
198
- {clearable && selectedDate && !disabled && (
199
- <span
200
- role="button"
201
- tabIndex={0}
202
- aria-label="Clear date"
203
- onClick={handleClear}
204
- onKeyDown={(e) => e.key === 'Enter' && handleClear(e as unknown as React.MouseEvent)}
205
- className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
206
- >
207
- <X className="w-3.5 h-3.5" />
208
- </span>
209
- )}
210
- </div>
211
- </button>
212
- }
213
- >
214
- <div
215
- role="dialog"
216
- aria-label="Choose date"
217
- className="p-4 w-72 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
218
- onKeyDown={handleGridKeyDown}
219
- >
220
- {/* Header: Month Year + Prev/Next */}
221
- <div className="flex items-center justify-between">
222
- <span
223
- id="calendar-month-heading"
224
- className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider"
225
- aria-live="polite"
226
- >
227
- {viewDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
228
- </span>
229
- <div className="flex items-center gap-1">
230
- <button
231
- type="button"
232
- onClick={prevMonth}
233
- aria-label="Previous month"
234
- className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
235
- >
236
- <ChevronLeft className="w-4 h-4" />
237
- </button>
238
- <button
239
- type="button"
240
- onClick={nextMonth}
241
- aria-label="Next month"
242
- className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
243
- >
244
- <ChevronRight className="w-4 h-4" />
245
- </button>
246
- </div>
247
- </div>
248
-
249
- {/* Presets if provided */}
250
- {presets && presets.length > 0 && (
251
- <div className="flex flex-wrap gap-1 pb-2 border-b border-[var(--ui-border-subtle)]">
252
- {presets.map((preset, idx) => (
253
- <button
254
- key={idx}
255
- type="button"
256
- onClick={() => handleSelectDate(preset.date)}
257
- className="px-2 py-0.5 rounded-md text-[10px] font-semibold bg-[var(--ui-bg-secondary)] hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-secondary)] transition-colors cursor-pointer"
258
- >
259
- {preset.label}
260
- </button>
261
- ))}
262
- </div>
263
- )}
264
-
265
- {/* Weekday headers */}
266
- <div
267
- role="row"
268
- className="grid grid-cols-7 text-center text-[10px] font-bold text-[var(--ui-text-muted)] font-mono"
269
- >
270
- {[
271
- { short: 'Su', full: 'Sunday' },
272
- { short: 'Mo', full: 'Monday' },
273
- { short: 'Tu', full: 'Tuesday' },
274
- { short: 'We', full: 'Wednesday' },
275
- { short: 'Th', full: 'Thursday' },
276
- { short: 'Fr', full: 'Friday' },
277
- { short: 'Sa', full: 'Saturday' },
278
- ].map((d) => (
279
- <div key={d.short} role="columnheader" aria-label={d.full} className="py-1">
280
- {d.short}
281
- </div>
282
- ))}
283
- </div>
284
-
285
- {/* Day grid */}
286
- <div
287
- role="grid"
288
- aria-labelledby="calendar-month-heading"
289
- className="grid grid-cols-7 gap-1"
290
- >
291
- {days.map((item, idx) => {
292
- const itemStr = formatDate(item.date);
293
- const isSelected = itemStr === selectedStr;
294
- const isToday = itemStr === todayStr;
295
- const isFocused = itemStr === focusedStr;
296
-
297
- const fullDateLabel = item.date.toLocaleDateString('en-US', {
298
- weekday: 'long',
299
- year: 'numeric',
300
- month: 'long',
301
- day: 'numeric',
302
- });
303
-
304
- return (
305
- <button
306
- key={idx}
307
- type="button"
308
- role="gridcell"
309
- disabled={item.disabled}
310
- aria-selected={isSelected}
311
- aria-current={isToday ? 'date' : undefined}
312
- aria-label={fullDateLabel}
313
- tabIndex={isFocused ? 0 : -1}
314
- onClick={() => handleSelectDate(item.date)}
315
- className={cn(
316
- 'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer outline-none',
317
- item.disabled && 'opacity-25 cursor-not-allowed pointer-events-none',
318
- !item.currentMonth && 'text-[var(--ui-text-muted)] opacity-50',
319
- isFocused && 'ring-2 ring-[var(--ui-primary)]',
320
- isSelected
321
- ? 'bg-[var(--ui-primary)] text-white font-bold shadow-md shadow-blue-500/30'
322
- : isToday
323
- ? 'border border-[var(--ui-primary)] text-[var(--ui-primary)]'
324
- : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
325
- )}
326
- >
327
- {item.date.getDate()}
328
- </button>
329
- );
330
- })}
331
- </div>
332
- </div>
333
- </Popover>
334
- </div>
335
- );
336
- }
337
- );
338
- DatePicker.displayName = 'DatePicker';
339
-
340
- /* -------------------------------------------------------------------------------------------------
341
- * DateRangePicker Component
342
- * -----------------------------------------------------------------------------------------------*/
343
- export interface DateRange {
344
- from: Date | null;
345
- to: Date | null;
346
- }
347
-
348
- export interface DateRangePickerProps {
349
- value?: DateRange;
350
- defaultValue?: DateRange;
351
- onChange?: (range: DateRange) => void;
352
- placeholder?: string;
353
- className?: string;
354
- disabled?: boolean;
355
- }
356
-
357
- export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
358
- ({ value: controlledVal, defaultValue = { from: null, to: null }, onChange, placeholder = 'Select date range...', className, disabled = false }, ref) => {
359
- const [range, setRange] = useControllableState<DateRange>({
360
- value: controlledVal,
361
- defaultValue,
362
- onChange,
363
- name: 'DateRangePicker',
364
- });
365
-
366
- const [viewDate, setViewDate] = useState<Date>(range.from || new Date());
367
- const [isOpen, setIsOpen] = useState(false);
368
-
369
- const handleDayClick = (d: Date) => {
370
- let next: DateRange;
371
- if (!range.from || (range.from && range.to)) {
372
- next = { from: d, to: null };
373
- } else {
374
- if (d < range.from) {
375
- next = { from: d, to: range.from };
376
- } else {
377
- next = { from: range.from, to: d };
378
- setIsOpen(false);
379
- }
380
- }
381
- setRange(next);
382
- };
383
-
384
- const displayText =
385
- range.from && range.to
386
- ? `${formatDisplayDate(range.from)} – ${formatDisplayDate(range.to)}`
387
- : range.from
388
- ? `${formatDisplayDate(range.from)} – ...`
389
- : placeholder;
390
-
391
- // Calendar generation
392
- const year = viewDate.getFullYear();
393
- const month = viewDate.getMonth();
394
- const firstDayIndex = new Date(year, month, 1).getDay();
395
- const daysInMonth = new Date(year, month + 1, 0).getDate();
396
-
397
- const days: Date[] = [];
398
- for (let i = 1; i <= daysInMonth; i++) {
399
- days.push(new Date(year, month, i));
400
- }
401
-
402
- return (
403
- <div ref={ref} className={cn('relative inline-block w-full', className)}>
404
- <Popover
405
- open={isOpen}
406
- onOpenChange={setIsOpen}
407
- trigger={
408
- <button
409
- type="button"
410
- disabled={disabled}
411
- aria-haspopup="dialog"
412
- aria-expanded={isOpen}
413
- aria-label={displayText}
414
- className="w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]"
415
- >
416
- <div className="flex items-center gap-2.5 truncate">
417
- <CalendarIcon className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
418
- <span className={range.from ? 'text-[var(--ui-text-primary)] font-medium' : 'text-[var(--ui-text-muted)]'}>
419
- {displayText}
420
- </span>
421
- </div>
422
- </button>
423
- }
424
- >
425
- <div
426
- role="dialog"
427
- aria-label="Choose date range"
428
- className="p-4 w-76 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
429
- >
430
- <div className="flex items-center justify-between">
431
- <span id="range-month-heading" className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider">
432
- {viewDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
433
- </span>
434
- <div className="flex items-center gap-1">
435
- <button
436
- type="button"
437
- onClick={() => setViewDate(new Date(year, month - 1, 1))}
438
- aria-label="Previous month"
439
- className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] cursor-pointer"
440
- >
441
- <ChevronLeft className="w-4 h-4" />
442
- </button>
443
- <button
444
- type="button"
445
- onClick={() => setViewDate(new Date(year, month + 1, 1))}
446
- aria-label="Next month"
447
- className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] cursor-pointer"
448
- >
449
- <ChevronRight className="w-4 h-4" />
450
- </button>
451
- </div>
452
- </div>
453
-
454
- <div
455
- role="grid"
456
- aria-labelledby="range-month-heading"
457
- className="grid grid-cols-7 gap-1 text-center"
458
- >
459
- {Array.from({ length: firstDayIndex }).map((_, i) => (
460
- <div key={`empty-${i}`} />
461
- ))}
462
- {days.map((d, idx) => {
463
- const dStr = formatDate(d);
464
- const fromStr = range.from ? formatDate(range.from) : '';
465
- const toStr = range.to ? formatDate(range.to) : '';
466
- const isFrom = dStr === fromStr;
467
- const isTo = dStr === toStr;
468
- const isInRange =
469
- range.from && range.to && d > range.from && d < range.to;
470
-
471
- return (
472
- <button
473
- key={idx}
474
- type="button"
475
- role="gridcell"
476
- aria-selected={Boolean(isFrom || isTo || isInRange)}
477
- aria-label={d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
478
- onClick={() => handleDayClick(d)}
479
- className={cn(
480
- 'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer',
481
- (isFrom || isTo) && 'bg-[var(--ui-primary)] text-white shadow-md',
482
- isInRange && 'bg-[var(--ui-primary)]/20 text-[var(--ui-primary)] rounded-none',
483
- !isFrom && !isTo && !isInRange && 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
484
- )}
485
- >
486
- {d.getDate()}
487
- </button>
488
- );
489
- })}
490
- </div>
491
- </div>
492
- </Popover>
493
- </div>
494
- );
495
- }
496
- );
497
- DateRangePicker.displayName = 'DateRangePicker';
498
-
499
- /* -------------------------------------------------------------------------------------------------
500
- * TimePicker Component
501
- * -----------------------------------------------------------------------------------------------*/
502
- export interface TimePickerProps {
503
- value?: string;
504
- defaultValue?: string;
505
- onChange?: (time: string) => void;
506
- format?: '12h' | '24h';
507
- className?: string;
508
- disabled?: boolean;
509
- placeholder?: string;
510
- }
511
-
512
- export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
513
- (
514
- {
515
- value: controlledVal,
516
- defaultValue,
517
- onChange,
518
- format = '24h',
519
- className,
520
- disabled = false,
521
- placeholder = 'Select time...',
522
- },
523
- ref
524
- ) => {
525
- const is12h = format === '12h';
526
- const fallbackDefault = defaultValue ?? (is12h ? '12:00 PM' : '12:00');
527
-
528
- const [time, setTime] = useControllableState<string>({
529
- value: controlledVal,
530
- defaultValue: fallbackDefault,
531
- onChange,
532
- name: 'TimePicker',
533
- });
534
-
535
- const [isOpen, setIsOpen] = useState(false);
536
-
537
- // Parsing helper
538
- const parseTime = (str: string, is12: boolean) => {
539
- if (!str) return { hours: is12 ? '12' : '12', minutes: '00', period: 'AM' };
540
- const trimmed = str.trim();
541
- let period = 'AM';
542
- let cleanTime = trimmed;
543
- if (trimmed.toLowerCase().includes('pm')) {
544
- period = 'PM';
545
- cleanTime = trimmed.replace(/pm/i, '').trim();
546
- } else if (trimmed.toLowerCase().includes('am')) {
547
- period = 'AM';
548
- cleanTime = trimmed.replace(/am/i, '').trim();
549
- }
550
- const parts = cleanTime.split(':');
551
- let h = parts[0] ? parts[0].padStart(2, '0') : is12 ? '12' : '00';
552
- const m = parts[1] ? parts[1].padStart(2, '0') : '00';
553
- return { hours: h, minutes: m, period };
554
- };
555
-
556
- const { hours, minutes, period } = parseTime(time, is12h);
557
-
558
- const updateTime = (newH: string, newM: string, newPeriod?: string) => {
559
- let formatted: string;
560
- if (is12h) {
561
- const p = newPeriod ?? period;
562
- formatted = `${newH.padStart(2, '0')}:${newM.padStart(2, '0')} ${p}`;
563
- } else {
564
- formatted = `${newH.padStart(2, '0')}:${newM.padStart(2, '0')}`;
565
- }
566
- setTime(formatted);
567
- };
568
-
569
- const hourOptions = is12h
570
- ? Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'))
571
- : Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
572
-
573
- const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
574
-
575
- const hourListRef = useRef<HTMLDivElement>(null);
576
- const minuteListRef = useRef<HTMLDivElement>(null);
577
- const periodListRef = useRef<HTMLDivElement>(null);
578
-
579
- // Auto-focus active option when opened
580
- useEffect(() => {
581
- if (isOpen) {
582
- const timer = setTimeout(() => {
583
- const activeHourEl = hourListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
584
- activeHourEl?.focus();
585
- }, 10);
586
- return () => clearTimeout(timer);
587
- }
588
- }, [isOpen]);
589
-
590
- const handleHourKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
591
- const idx = hourOptions.indexOf(hours);
592
- if (e.key === 'ArrowDown') {
593
- e.preventDefault();
594
- const next = hourOptions[(idx + 1) % hourOptions.length];
595
- updateTime(next, minutes, period);
596
- } else if (e.key === 'ArrowUp') {
597
- e.preventDefault();
598
- const prev = hourOptions[(idx - 1 + hourOptions.length) % hourOptions.length];
599
- updateTime(prev, minutes, period);
600
- } else if (e.key === 'ArrowRight') {
601
- e.preventDefault();
602
- const activeMinEl = minuteListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]') || minuteListRef.current?.querySelector<HTMLElement>('[role="option"]');
603
- activeMinEl?.focus();
604
- } else if (e.key === 'Escape') {
605
- e.preventDefault();
606
- setIsOpen(false);
607
- }
608
- };
609
-
610
- const handleMinuteKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
611
- const idx = minuteOptions.indexOf(minutes);
612
- if (e.key === 'ArrowDown') {
613
- e.preventDefault();
614
- const next = minuteOptions[(idx + 1) % minuteOptions.length];
615
- updateTime(hours, next, period);
616
- } else if (e.key === 'ArrowUp') {
617
- e.preventDefault();
618
- const prev = minuteOptions[(idx - 1 + minuteOptions.length) % minuteOptions.length];
619
- updateTime(hours, prev, period);
620
- } else if (e.key === 'ArrowLeft') {
621
- e.preventDefault();
622
- const activeHourEl = hourListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
623
- activeHourEl?.focus();
624
- } else if (e.key === 'ArrowRight' && is12h) {
625
- e.preventDefault();
626
- const activePeriodEl = periodListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
627
- activePeriodEl?.focus();
628
- } else if (e.key === 'Escape') {
629
- e.preventDefault();
630
- setIsOpen(false);
631
- }
632
- };
633
-
634
- const handlePeriodKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
635
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
636
- e.preventDefault();
637
- const next = period === 'AM' ? 'PM' : 'AM';
638
- updateTime(hours, minutes, next);
639
- } else if (e.key === 'ArrowLeft') {
640
- e.preventDefault();
641
- const activeMinEl = minuteListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
642
- activeMinEl?.focus();
643
- } else if (e.key === 'Escape') {
644
- e.preventDefault();
645
- setIsOpen(false);
646
- }
647
- };
648
-
649
- return (
650
- <div ref={ref} className={cn('relative inline-block w-full', className)}>
651
- <Popover
652
- open={isOpen}
653
- onOpenChange={setIsOpen}
654
- trigger={
655
- <button
656
- type="button"
657
- disabled={disabled}
658
- aria-haspopup="dialog"
659
- aria-expanded={isOpen}
660
- aria-label={`Selected time: ${time}`}
661
- className={cn(
662
- 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm shadow-inner transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
663
- disabled && 'opacity-50 cursor-not-allowed'
664
- )}
665
- >
666
- <div className="flex items-center gap-2.5">
667
- <Clock className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
668
- <span className="font-mono text-sm font-semibold text-[var(--ui-text-primary)]">
669
- {time || placeholder}
670
- </span>
671
- </div>
672
- </button>
673
- }
674
- >
675
- <div
676
- role="dialog"
677
- aria-label="Choose time"
678
- className="p-4 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
679
- >
680
- <span className="text-xs font-bold text-[var(--ui-text-muted)] uppercase tracking-wider font-mono">
681
- Select Time ({format})
682
- </span>
683
- <div className="flex items-center justify-center gap-2">
684
- {/* Hours */}
685
- <div
686
- ref={hourListRef}
687
- tabIndex={0}
688
- onKeyDown={handleHourKeyDown}
689
- className="h-40 overflow-y-auto border border-[var(--ui-border-subtle)] rounded-xl p-1 w-16 text-center space-y-1 focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
690
- role="listbox"
691
- aria-label="Hour"
692
- >
693
- {hourOptions.map((h) => {
694
- const isSelected = hours === h;
695
- return (
696
- <div
697
- key={h}
698
- tabIndex={isSelected ? 0 : -1}
699
- role="option"
700
- aria-selected={isSelected}
701
- onClick={() => updateTime(h, minutes, period)}
702
- className={cn(
703
- 'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
704
- isSelected
705
- ? 'bg-[var(--ui-primary)] text-white'
706
- : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
707
- )}
708
- >
709
- {h}
710
- </div>
711
- );
712
- })}
713
- </div>
714
- <span className="text-sm font-bold">:</span>
715
- {/* Minutes */}
716
- <div
717
- ref={minuteListRef}
718
- tabIndex={0}
719
- onKeyDown={handleMinuteKeyDown}
720
- className="h-40 overflow-y-auto border border-[var(--ui-border-subtle)] rounded-xl p-1 w-16 text-center space-y-1 focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
721
- role="listbox"
722
- aria-label="Minute"
723
- >
724
- {minuteOptions.map((m) => {
725
- const isSelected = minutes === m;
726
- return (
727
- <div
728
- key={m}
729
- tabIndex={isSelected ? 0 : -1}
730
- role="option"
731
- aria-selected={isSelected}
732
- onClick={() => updateTime(hours, m, period)}
733
- className={cn(
734
- 'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
735
- isSelected
736
- ? 'bg-[var(--ui-primary)] text-white'
737
- : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
738
- )}
739
- >
740
- {m}
741
- </div>
742
- );
743
- })}
744
- </div>
745
- {/* Period (AM/PM) for 12h */}
746
- {is12h && (
747
- <div
748
- ref={periodListRef}
749
- tabIndex={0}
750
- onKeyDown={handlePeriodKeyDown}
751
- className="h-40 border border-[var(--ui-border-subtle)] rounded-xl p-1 w-14 text-center space-y-1 flex flex-col justify-center focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
752
- role="listbox"
753
- aria-label="Period"
754
- >
755
- {['AM', 'PM'].map((p) => {
756
- const isSelected = period === p;
757
- return (
758
- <div
759
- key={p}
760
- tabIndex={isSelected ? 0 : -1}
761
- role="option"
762
- aria-selected={isSelected}
763
- onClick={() => updateTime(hours, minutes, p)}
764
- className={cn(
765
- 'text-xs py-2 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
766
- isSelected
767
- ? 'bg-[var(--ui-primary)] text-white'
768
- : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
769
- )}
770
- >
771
- {p}
772
- </div>
773
- );
774
- })}
775
- </div>
776
- )}
777
- </div>
778
- </div>
779
- </Popover>
780
- </div>
781
- );
782
- }
783
- );
784
- TimePicker.displayName = 'TimePicker';
1
+ import React, { forwardRef, useState, useRef, useEffect } from 'react';
2
+ import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight, X } from 'lucide-react';
3
+ import { cn } from '../utils/cn';
4
+ import { Popover } from '../overlays/Popover';
5
+ import { useControllableState } from '../utils/useControllableState';
6
+
7
+ // Utility helper to format date as YYYY-MM-DD
8
+ export function formatDate(d: Date | null | undefined): string {
9
+ if (!d) return '';
10
+ const year = d.getFullYear();
11
+ const month = String(d.getMonth() + 1).padStart(2, '0');
12
+ const day = String(d.getDate()).padStart(2, '0');
13
+ return `${year}-${month}-${day}`;
14
+ }
15
+
16
+ export function formatDisplayDate(d: Date | null | undefined): string {
17
+ if (!d) return '';
18
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
19
+ }
20
+
21
+ export interface DatePickerProps {
22
+ value?: Date | null;
23
+ defaultValue?: Date | null;
24
+ onChange?: (date: Date | null) => void;
25
+ placeholder?: string;
26
+ minDate?: Date;
27
+ maxDate?: Date;
28
+ disabled?: boolean;
29
+ clearable?: boolean;
30
+ className?: string;
31
+ presets?: { label: string; date: Date }[];
32
+ }
33
+
34
+ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
35
+ (
36
+ {
37
+ value: controlledValue,
38
+ defaultValue = null,
39
+ onChange,
40
+ placeholder = 'Select date...',
41
+ minDate,
42
+ maxDate,
43
+ disabled = false,
44
+ clearable = true,
45
+ className,
46
+ presets,
47
+ },
48
+ ref
49
+ ) => {
50
+ const [selectedDate, setSelectedDate] = useControllableState<Date | null>({
51
+ value: controlledValue,
52
+ defaultValue,
53
+ onChange,
54
+ name: 'DatePicker',
55
+ });
56
+
57
+ const [viewDate, setViewDate] = useState<Date>(selectedDate || new Date());
58
+ const [isOpen, setIsOpen] = useState(false);
59
+ const [focusedDate, setFocusedDate] = useState<Date>(selectedDate || new Date());
60
+
61
+ useEffect(() => {
62
+ if (selectedDate) {
63
+ setViewDate(selectedDate);
64
+ setFocusedDate(selectedDate);
65
+ }
66
+ }, [selectedDate]);
67
+
68
+ const handleSelectDate = (d: Date) => {
69
+ setSelectedDate(d);
70
+ setIsOpen(false);
71
+ };
72
+
73
+ const handleClear = (e: React.MouseEvent) => {
74
+ e.stopPropagation();
75
+ setSelectedDate(null);
76
+ };
77
+
78
+ const prevMonth = () => {
79
+ setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
80
+ };
81
+
82
+ const nextMonth = () => {
83
+ setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
84
+ };
85
+
86
+ // Calendar generation logic
87
+ const year = viewDate.getFullYear();
88
+ const month = viewDate.getMonth();
89
+ const firstDayIndex = new Date(year, month, 1).getDay();
90
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
91
+ const daysInPrevMonth = new Date(year, month, 0).getDate();
92
+
93
+ const days: { date: Date; currentMonth: boolean; disabled: boolean }[] = [];
94
+
95
+ // Prev month padding days
96
+ for (let i = firstDayIndex - 1; i >= 0; i--) {
97
+ const d = new Date(year, month - 1, daysInPrevMonth - i);
98
+ const isDis = (minDate && d < minDate) || (maxDate && d > maxDate);
99
+ days.push({ date: d, currentMonth: false, disabled: Boolean(isDis) });
100
+ }
101
+
102
+ // Current month days
103
+ for (let i = 1; i <= daysInMonth; i++) {
104
+ const d = new Date(year, month, i);
105
+ const isDis = (minDate && d < minDate) || (maxDate && d > maxDate);
106
+ days.push({ date: d, currentMonth: true, disabled: Boolean(isDis) });
107
+ }
108
+
109
+ // Next month padding days to complete grid of 35 or 42
110
+ const totalSlots = days.length > 35 ? 42 : 35;
111
+ const remainingSlots = totalSlots - days.length;
112
+ for (let i = 1; i <= remainingSlots; i++) {
113
+ const d = new Date(year, month + 1, i);
114
+ const isDis = (minDate && d < minDate) || (maxDate && d > maxDate);
115
+ days.push({ date: d, currentMonth: false, disabled: Boolean(isDis) });
116
+ }
117
+
118
+ const todayStr = formatDate(new Date());
119
+ const selectedStr = selectedDate ? formatDate(selectedDate) : '';
120
+ const focusedStr = formatDate(focusedDate);
121
+
122
+ // Keyboard navigation within the calendar grid
123
+ const handleGridKeyDown = (e: React.KeyboardEvent) => {
124
+ let nextDate = new Date(focusedDate);
125
+ let handled = false;
126
+
127
+ if (e.key === 'ArrowLeft') {
128
+ nextDate.setDate(nextDate.getDate() - 1);
129
+ handled = true;
130
+ } else if (e.key === 'ArrowRight') {
131
+ nextDate.setDate(nextDate.getDate() + 1);
132
+ handled = true;
133
+ } else if (e.key === 'ArrowUp') {
134
+ nextDate.setDate(nextDate.getDate() - 7);
135
+ handled = true;
136
+ } else if (e.key === 'ArrowDown') {
137
+ nextDate.setDate(nextDate.getDate() + 7);
138
+ handled = true;
139
+ } else if (e.key === 'PageUp') {
140
+ nextDate.setMonth(nextDate.getMonth() - 1);
141
+ handled = true;
142
+ } else if (e.key === 'PageDown') {
143
+ nextDate.setMonth(nextDate.getMonth() + 1);
144
+ handled = true;
145
+ } else if (e.key === 'Home') {
146
+ nextDate.setDate(1);
147
+ handled = true;
148
+ } else if (e.key === 'End') {
149
+ const lastDay = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate();
150
+ nextDate.setDate(lastDay);
151
+ handled = true;
152
+ } else if (e.key === 'Enter' || e.key === ' ') {
153
+ e.preventDefault();
154
+ handleSelectDate(focusedDate);
155
+ return;
156
+ } else if (e.key === 'Escape') {
157
+ e.preventDefault();
158
+ setIsOpen(false);
159
+ return;
160
+ }
161
+
162
+ if (handled) {
163
+ e.preventDefault();
164
+ setFocusedDate(nextDate);
165
+ if (nextDate.getMonth() !== viewDate.getMonth() || nextDate.getFullYear() !== viewDate.getFullYear()) {
166
+ setViewDate(new Date(nextDate.getFullYear(), nextDate.getMonth(), 1));
167
+ }
168
+ }
169
+ };
170
+
171
+ return (
172
+ <div ref={ref} className={cn('relative inline-block w-full', className)}>
173
+ <Popover
174
+ open={isOpen}
175
+ onOpenChange={setIsOpen}
176
+ trigger={
177
+ <button
178
+ type="button"
179
+ disabled={disabled}
180
+ aria-haspopup="dialog"
181
+ aria-expanded={isOpen}
182
+ aria-label={selectedDate ? `Selected date: ${formatDisplayDate(selectedDate)}` : placeholder}
183
+ className={cn(
184
+ 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
185
+ disabled
186
+ ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
187
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
188
+ )}
189
+ >
190
+ <div className="flex items-center gap-2.5 truncate">
191
+ <CalendarIcon className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
192
+ <span className={selectedDate ? 'text-[var(--ui-text-primary)] font-medium' : 'text-[var(--ui-text-muted)]'}>
193
+ {selectedDate ? formatDisplayDate(selectedDate) : placeholder}
194
+ </span>
195
+ </div>
196
+
197
+ <div className="flex items-center gap-1 text-[var(--ui-text-muted)]">
198
+ {clearable && selectedDate && !disabled && (
199
+ <span
200
+ role="button"
201
+ tabIndex={0}
202
+ aria-label="Clear date"
203
+ onClick={handleClear}
204
+ onKeyDown={(e) => e.key === 'Enter' && handleClear(e as unknown as React.MouseEvent)}
205
+ className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
206
+ >
207
+ <X className="w-3.5 h-3.5" />
208
+ </span>
209
+ )}
210
+ </div>
211
+ </button>
212
+ }
213
+ >
214
+ <div
215
+ role="dialog"
216
+ aria-label="Choose date"
217
+ className="p-4 w-72 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
218
+ onKeyDown={handleGridKeyDown}
219
+ >
220
+ {/* Header: Month Year + Prev/Next */}
221
+ <div className="flex items-center justify-between">
222
+ <span
223
+ id="calendar-month-heading"
224
+ className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider"
225
+ aria-live="polite"
226
+ >
227
+ {viewDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
228
+ </span>
229
+ <div className="flex items-center gap-1">
230
+ <button
231
+ type="button"
232
+ onClick={prevMonth}
233
+ aria-label="Previous month"
234
+ className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
235
+ >
236
+ <ChevronLeft className="w-4 h-4" />
237
+ </button>
238
+ <button
239
+ type="button"
240
+ onClick={nextMonth}
241
+ aria-label="Next month"
242
+ className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
243
+ >
244
+ <ChevronRight className="w-4 h-4" />
245
+ </button>
246
+ </div>
247
+ </div>
248
+
249
+ {/* Presets if provided */}
250
+ {presets && presets.length > 0 && (
251
+ <div className="flex flex-wrap gap-1 pb-2 border-b border-[var(--ui-border-subtle)]">
252
+ {presets.map((preset, idx) => (
253
+ <button
254
+ key={idx}
255
+ type="button"
256
+ onClick={() => handleSelectDate(preset.date)}
257
+ className="px-2 py-0.5 rounded-md text-[10px] font-semibold bg-[var(--ui-bg-secondary)] hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-secondary)] transition-colors cursor-pointer"
258
+ >
259
+ {preset.label}
260
+ </button>
261
+ ))}
262
+ </div>
263
+ )}
264
+
265
+ {/* Weekday headers */}
266
+ <div
267
+ role="row"
268
+ className="grid grid-cols-7 text-center text-[10px] font-bold text-[var(--ui-text-muted)] font-mono"
269
+ >
270
+ {[
271
+ { short: 'Su', full: 'Sunday' },
272
+ { short: 'Mo', full: 'Monday' },
273
+ { short: 'Tu', full: 'Tuesday' },
274
+ { short: 'We', full: 'Wednesday' },
275
+ { short: 'Th', full: 'Thursday' },
276
+ { short: 'Fr', full: 'Friday' },
277
+ { short: 'Sa', full: 'Saturday' },
278
+ ].map((d) => (
279
+ <div key={d.short} role="columnheader" aria-label={d.full} className="py-1">
280
+ {d.short}
281
+ </div>
282
+ ))}
283
+ </div>
284
+
285
+ {/* Day grid */}
286
+ <div
287
+ role="grid"
288
+ aria-labelledby="calendar-month-heading"
289
+ className="grid grid-cols-7 gap-1"
290
+ >
291
+ {days.map((item, idx) => {
292
+ const itemStr = formatDate(item.date);
293
+ const isSelected = itemStr === selectedStr;
294
+ const isToday = itemStr === todayStr;
295
+ const isFocused = itemStr === focusedStr;
296
+
297
+ const fullDateLabel = item.date.toLocaleDateString('en-US', {
298
+ weekday: 'long',
299
+ year: 'numeric',
300
+ month: 'long',
301
+ day: 'numeric',
302
+ });
303
+
304
+ return (
305
+ <button
306
+ key={idx}
307
+ type="button"
308
+ role="gridcell"
309
+ disabled={item.disabled}
310
+ aria-selected={isSelected}
311
+ aria-current={isToday ? 'date' : undefined}
312
+ aria-label={fullDateLabel}
313
+ tabIndex={isFocused ? 0 : -1}
314
+ onClick={() => handleSelectDate(item.date)}
315
+ className={cn(
316
+ 'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer outline-none',
317
+ item.disabled && 'opacity-25 cursor-not-allowed pointer-events-none',
318
+ !item.currentMonth && 'text-[var(--ui-text-muted)] opacity-50',
319
+ isFocused && 'ring-2 ring-[var(--ui-primary)]',
320
+ isSelected
321
+ ? 'bg-[var(--ui-primary)] text-white font-bold shadow-md shadow-blue-500/30'
322
+ : isToday
323
+ ? 'border border-[var(--ui-primary)] text-[var(--ui-primary)]'
324
+ : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
325
+ )}
326
+ >
327
+ {item.date.getDate()}
328
+ </button>
329
+ );
330
+ })}
331
+ </div>
332
+ </div>
333
+ </Popover>
334
+ </div>
335
+ );
336
+ }
337
+ );
338
+ DatePicker.displayName = 'DatePicker';
339
+
340
+ /* -------------------------------------------------------------------------------------------------
341
+ * DateRangePicker Component
342
+ * -----------------------------------------------------------------------------------------------*/
343
+ export interface DateRange {
344
+ from: Date | null;
345
+ to: Date | null;
346
+ }
347
+
348
+ export interface DateRangePickerProps {
349
+ value?: DateRange;
350
+ defaultValue?: DateRange;
351
+ onChange?: (range: DateRange) => void;
352
+ placeholder?: string;
353
+ className?: string;
354
+ disabled?: boolean;
355
+ }
356
+
357
+ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
358
+ ({ value: controlledVal, defaultValue = { from: null, to: null }, onChange, placeholder = 'Select date range...', className, disabled = false }, ref) => {
359
+ const [range, setRange] = useControllableState<DateRange>({
360
+ value: controlledVal,
361
+ defaultValue,
362
+ onChange,
363
+ name: 'DateRangePicker',
364
+ });
365
+
366
+ const [viewDate, setViewDate] = useState<Date>(range.from || new Date());
367
+ const [isOpen, setIsOpen] = useState(false);
368
+
369
+ const handleDayClick = (d: Date) => {
370
+ let next: DateRange;
371
+ if (!range.from || (range.from && range.to)) {
372
+ next = { from: d, to: null };
373
+ } else {
374
+ if (d < range.from) {
375
+ next = { from: d, to: range.from };
376
+ } else {
377
+ next = { from: range.from, to: d };
378
+ setIsOpen(false);
379
+ }
380
+ }
381
+ setRange(next);
382
+ };
383
+
384
+ const displayText =
385
+ range.from && range.to
386
+ ? `${formatDisplayDate(range.from)} – ${formatDisplayDate(range.to)}`
387
+ : range.from
388
+ ? `${formatDisplayDate(range.from)} – ...`
389
+ : placeholder;
390
+
391
+ // Calendar generation
392
+ const year = viewDate.getFullYear();
393
+ const month = viewDate.getMonth();
394
+ const firstDayIndex = new Date(year, month, 1).getDay();
395
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
396
+
397
+ const days: Date[] = [];
398
+ for (let i = 1; i <= daysInMonth; i++) {
399
+ days.push(new Date(year, month, i));
400
+ }
401
+
402
+ return (
403
+ <div ref={ref} className={cn('relative inline-block w-full', className)}>
404
+ <Popover
405
+ open={isOpen}
406
+ onOpenChange={setIsOpen}
407
+ trigger={
408
+ <button
409
+ type="button"
410
+ disabled={disabled}
411
+ aria-haspopup="dialog"
412
+ aria-expanded={isOpen}
413
+ aria-label={displayText}
414
+ className="w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]"
415
+ >
416
+ <div className="flex items-center gap-2.5 truncate">
417
+ <CalendarIcon className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
418
+ <span className={range.from ? 'text-[var(--ui-text-primary)] font-medium' : 'text-[var(--ui-text-muted)]'}>
419
+ {displayText}
420
+ </span>
421
+ </div>
422
+ </button>
423
+ }
424
+ >
425
+ <div
426
+ role="dialog"
427
+ aria-label="Choose date range"
428
+ className="p-4 w-76 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
429
+ >
430
+ <div className="flex items-center justify-between">
431
+ <span id="range-month-heading" className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider">
432
+ {viewDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
433
+ </span>
434
+ <div className="flex items-center gap-1">
435
+ <button
436
+ type="button"
437
+ onClick={() => setViewDate(new Date(year, month - 1, 1))}
438
+ aria-label="Previous month"
439
+ className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] cursor-pointer"
440
+ >
441
+ <ChevronLeft className="w-4 h-4" />
442
+ </button>
443
+ <button
444
+ type="button"
445
+ onClick={() => setViewDate(new Date(year, month + 1, 1))}
446
+ aria-label="Next month"
447
+ className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] cursor-pointer"
448
+ >
449
+ <ChevronRight className="w-4 h-4" />
450
+ </button>
451
+ </div>
452
+ </div>
453
+
454
+ <div
455
+ role="grid"
456
+ aria-labelledby="range-month-heading"
457
+ className="grid grid-cols-7 gap-1 text-center"
458
+ >
459
+ {Array.from({ length: firstDayIndex }).map((_, i) => (
460
+ <div key={`empty-${i}`} />
461
+ ))}
462
+ {days.map((d, idx) => {
463
+ const dStr = formatDate(d);
464
+ const fromStr = range.from ? formatDate(range.from) : '';
465
+ const toStr = range.to ? formatDate(range.to) : '';
466
+ const isFrom = dStr === fromStr;
467
+ const isTo = dStr === toStr;
468
+ const isInRange =
469
+ range.from && range.to && d > range.from && d < range.to;
470
+
471
+ return (
472
+ <button
473
+ key={idx}
474
+ type="button"
475
+ role="gridcell"
476
+ aria-selected={Boolean(isFrom || isTo || isInRange)}
477
+ aria-label={d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
478
+ onClick={() => handleDayClick(d)}
479
+ className={cn(
480
+ 'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer',
481
+ (isFrom || isTo) && 'bg-[var(--ui-primary)] text-white shadow-md',
482
+ isInRange && 'bg-[var(--ui-primary)]/20 text-[var(--ui-primary)] rounded-none',
483
+ !isFrom && !isTo && !isInRange && 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
484
+ )}
485
+ >
486
+ {d.getDate()}
487
+ </button>
488
+ );
489
+ })}
490
+ </div>
491
+ </div>
492
+ </Popover>
493
+ </div>
494
+ );
495
+ }
496
+ );
497
+ DateRangePicker.displayName = 'DateRangePicker';
498
+
499
+ /* -------------------------------------------------------------------------------------------------
500
+ * TimePicker Component
501
+ * -----------------------------------------------------------------------------------------------*/
502
+ export interface TimePickerProps {
503
+ value?: string;
504
+ defaultValue?: string;
505
+ onChange?: (time: string) => void;
506
+ format?: '12h' | '24h';
507
+ className?: string;
508
+ disabled?: boolean;
509
+ placeholder?: string;
510
+ }
511
+
512
+ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
513
+ (
514
+ {
515
+ value: controlledVal,
516
+ defaultValue,
517
+ onChange,
518
+ format = '24h',
519
+ className,
520
+ disabled = false,
521
+ placeholder = 'Select time...',
522
+ },
523
+ ref
524
+ ) => {
525
+ const is12h = format === '12h';
526
+ const fallbackDefault = defaultValue ?? (is12h ? '12:00 PM' : '12:00');
527
+
528
+ const [time, setTime] = useControllableState<string>({
529
+ value: controlledVal,
530
+ defaultValue: fallbackDefault,
531
+ onChange,
532
+ name: 'TimePicker',
533
+ });
534
+
535
+ const [isOpen, setIsOpen] = useState(false);
536
+
537
+ // Parsing helper
538
+ const parseTime = (str: string, is12: boolean) => {
539
+ if (!str) return { hours: is12 ? '12' : '12', minutes: '00', period: 'AM' };
540
+ const trimmed = str.trim();
541
+ let period = 'AM';
542
+ let cleanTime = trimmed;
543
+ if (trimmed.toLowerCase().includes('pm')) {
544
+ period = 'PM';
545
+ cleanTime = trimmed.replace(/pm/i, '').trim();
546
+ } else if (trimmed.toLowerCase().includes('am')) {
547
+ period = 'AM';
548
+ cleanTime = trimmed.replace(/am/i, '').trim();
549
+ }
550
+ const parts = cleanTime.split(':');
551
+ let h = parts[0] ? parts[0].padStart(2, '0') : is12 ? '12' : '00';
552
+ const m = parts[1] ? parts[1].padStart(2, '0') : '00';
553
+ return { hours: h, minutes: m, period };
554
+ };
555
+
556
+ const { hours, minutes, period } = parseTime(time, is12h);
557
+
558
+ const updateTime = (newH: string, newM: string, newPeriod?: string) => {
559
+ let formatted: string;
560
+ if (is12h) {
561
+ const p = newPeriod ?? period;
562
+ formatted = `${newH.padStart(2, '0')}:${newM.padStart(2, '0')} ${p}`;
563
+ } else {
564
+ formatted = `${newH.padStart(2, '0')}:${newM.padStart(2, '0')}`;
565
+ }
566
+ setTime(formatted);
567
+ };
568
+
569
+ const hourOptions = is12h
570
+ ? Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'))
571
+ : Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
572
+
573
+ const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
574
+
575
+ const hourListRef = useRef<HTMLDivElement>(null);
576
+ const minuteListRef = useRef<HTMLDivElement>(null);
577
+ const periodListRef = useRef<HTMLDivElement>(null);
578
+
579
+ // Auto-focus active option when opened
580
+ useEffect(() => {
581
+ if (isOpen) {
582
+ const timer = setTimeout(() => {
583
+ const activeHourEl = hourListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
584
+ activeHourEl?.focus();
585
+ }, 10);
586
+ return () => clearTimeout(timer);
587
+ }
588
+ }, [isOpen]);
589
+
590
+ const handleHourKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
591
+ const idx = hourOptions.indexOf(hours);
592
+ if (e.key === 'ArrowDown') {
593
+ e.preventDefault();
594
+ const next = hourOptions[(idx + 1) % hourOptions.length];
595
+ updateTime(next, minutes, period);
596
+ } else if (e.key === 'ArrowUp') {
597
+ e.preventDefault();
598
+ const prev = hourOptions[(idx - 1 + hourOptions.length) % hourOptions.length];
599
+ updateTime(prev, minutes, period);
600
+ } else if (e.key === 'ArrowRight') {
601
+ e.preventDefault();
602
+ const activeMinEl = minuteListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]') || minuteListRef.current?.querySelector<HTMLElement>('[role="option"]');
603
+ activeMinEl?.focus();
604
+ } else if (e.key === 'Escape') {
605
+ e.preventDefault();
606
+ setIsOpen(false);
607
+ }
608
+ };
609
+
610
+ const handleMinuteKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
611
+ const idx = minuteOptions.indexOf(minutes);
612
+ if (e.key === 'ArrowDown') {
613
+ e.preventDefault();
614
+ const next = minuteOptions[(idx + 1) % minuteOptions.length];
615
+ updateTime(hours, next, period);
616
+ } else if (e.key === 'ArrowUp') {
617
+ e.preventDefault();
618
+ const prev = minuteOptions[(idx - 1 + minuteOptions.length) % minuteOptions.length];
619
+ updateTime(hours, prev, period);
620
+ } else if (e.key === 'ArrowLeft') {
621
+ e.preventDefault();
622
+ const activeHourEl = hourListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
623
+ activeHourEl?.focus();
624
+ } else if (e.key === 'ArrowRight' && is12h) {
625
+ e.preventDefault();
626
+ const activePeriodEl = periodListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
627
+ activePeriodEl?.focus();
628
+ } else if (e.key === 'Escape') {
629
+ e.preventDefault();
630
+ setIsOpen(false);
631
+ }
632
+ };
633
+
634
+ const handlePeriodKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
635
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
636
+ e.preventDefault();
637
+ const next = period === 'AM' ? 'PM' : 'AM';
638
+ updateTime(hours, minutes, next);
639
+ } else if (e.key === 'ArrowLeft') {
640
+ e.preventDefault();
641
+ const activeMinEl = minuteListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
642
+ activeMinEl?.focus();
643
+ } else if (e.key === 'Escape') {
644
+ e.preventDefault();
645
+ setIsOpen(false);
646
+ }
647
+ };
648
+
649
+ return (
650
+ <div ref={ref} className={cn('relative inline-block w-full', className)}>
651
+ <Popover
652
+ open={isOpen}
653
+ onOpenChange={setIsOpen}
654
+ trigger={
655
+ <button
656
+ type="button"
657
+ disabled={disabled}
658
+ aria-haspopup="dialog"
659
+ aria-expanded={isOpen}
660
+ aria-label={`Selected time: ${time}`}
661
+ className={cn(
662
+ 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm shadow-inner transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
663
+ disabled && 'opacity-50 cursor-not-allowed'
664
+ )}
665
+ >
666
+ <div className="flex items-center gap-2.5">
667
+ <Clock className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
668
+ <span className="font-mono text-sm font-semibold text-[var(--ui-text-primary)]">
669
+ {time || placeholder}
670
+ </span>
671
+ </div>
672
+ </button>
673
+ }
674
+ >
675
+ <div
676
+ role="dialog"
677
+ aria-label="Choose time"
678
+ className="p-4 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
679
+ >
680
+ <span className="text-xs font-bold text-[var(--ui-text-muted)] uppercase tracking-wider font-mono">
681
+ Select Time ({format})
682
+ </span>
683
+ <div className="flex items-center justify-center gap-2">
684
+ {/* Hours */}
685
+ <div
686
+ ref={hourListRef}
687
+ tabIndex={0}
688
+ onKeyDown={handleHourKeyDown}
689
+ className="h-40 overflow-y-auto border border-[var(--ui-border-subtle)] rounded-xl p-1 w-16 text-center space-y-1 focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
690
+ role="listbox"
691
+ aria-label="Hour"
692
+ >
693
+ {hourOptions.map((h) => {
694
+ const isSelected = hours === h;
695
+ return (
696
+ <div
697
+ key={h}
698
+ tabIndex={isSelected ? 0 : -1}
699
+ role="option"
700
+ aria-selected={isSelected}
701
+ onClick={() => updateTime(h, minutes, period)}
702
+ className={cn(
703
+ 'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
704
+ isSelected
705
+ ? 'bg-[var(--ui-primary)] text-white'
706
+ : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
707
+ )}
708
+ >
709
+ {h}
710
+ </div>
711
+ );
712
+ })}
713
+ </div>
714
+ <span className="text-sm font-bold">:</span>
715
+ {/* Minutes */}
716
+ <div
717
+ ref={minuteListRef}
718
+ tabIndex={0}
719
+ onKeyDown={handleMinuteKeyDown}
720
+ className="h-40 overflow-y-auto border border-[var(--ui-border-subtle)] rounded-xl p-1 w-16 text-center space-y-1 focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
721
+ role="listbox"
722
+ aria-label="Minute"
723
+ >
724
+ {minuteOptions.map((m) => {
725
+ const isSelected = minutes === m;
726
+ return (
727
+ <div
728
+ key={m}
729
+ tabIndex={isSelected ? 0 : -1}
730
+ role="option"
731
+ aria-selected={isSelected}
732
+ onClick={() => updateTime(hours, m, period)}
733
+ className={cn(
734
+ 'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
735
+ isSelected
736
+ ? 'bg-[var(--ui-primary)] text-white'
737
+ : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
738
+ )}
739
+ >
740
+ {m}
741
+ </div>
742
+ );
743
+ })}
744
+ </div>
745
+ {/* Period (AM/PM) for 12h */}
746
+ {is12h && (
747
+ <div
748
+ ref={periodListRef}
749
+ tabIndex={0}
750
+ onKeyDown={handlePeriodKeyDown}
751
+ className="h-40 border border-[var(--ui-border-subtle)] rounded-xl p-1 w-14 text-center space-y-1 flex flex-col justify-center focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
752
+ role="listbox"
753
+ aria-label="Period"
754
+ >
755
+ {['AM', 'PM'].map((p) => {
756
+ const isSelected = period === p;
757
+ return (
758
+ <div
759
+ key={p}
760
+ tabIndex={isSelected ? 0 : -1}
761
+ role="option"
762
+ aria-selected={isSelected}
763
+ onClick={() => updateTime(hours, minutes, p)}
764
+ className={cn(
765
+ 'text-xs py-2 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
766
+ isSelected
767
+ ? 'bg-[var(--ui-primary)] text-white'
768
+ : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
769
+ )}
770
+ >
771
+ {p}
772
+ </div>
773
+ );
774
+ })}
775
+ </div>
776
+ )}
777
+ </div>
778
+ </div>
779
+ </Popover>
780
+ </div>
781
+ );
782
+ }
783
+ );
784
+ TimePicker.displayName = 'TimePicker';