@thangnqdev/ui 0.2.1 → 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.
- package/LICENSE +21 -21
- package/README.md +144 -142
- package/bin/cli.js +125 -125
- package/dist/bento/index.cjs.map +1 -1
- package/dist/bento/index.js +2 -2
- package/dist/buttons/index.cjs.map +1 -1
- package/dist/buttons/index.js +3 -3
- package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
- package/dist/chunk-2OXZK6IP.js.map +1 -0
- package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
- package/dist/chunk-4GNFT2ZJ.js.map +1 -0
- package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
- package/dist/chunk-4S6L5V7X.js.map +1 -0
- package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
- package/dist/chunk-66VWUJEI.js.map +1 -0
- package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
- package/dist/chunk-7NMB2Y64.js.map +1 -0
- package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
- package/dist/chunk-A7P3O5Z5.js.map +1 -0
- package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
- package/dist/chunk-ATYGDTMK.js.map +1 -0
- package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
- package/dist/chunk-C4SUXMI3.js.map +1 -0
- package/dist/{chunk-EXFR2BFJ.js → chunk-DUPPPGXY.js} +7 -7
- package/dist/chunk-DUPPPGXY.js.map +1 -0
- package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
- package/dist/chunk-EU2LPUYP.js.map +1 -0
- package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
- package/dist/chunk-GXI2H5XA.js.map +1 -0
- package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
- package/dist/chunk-IABPUCAQ.js.map +1 -0
- package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
- package/dist/chunk-JOKEKOP3.js.map +1 -0
- package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
- package/dist/chunk-KUQWUTZA.js.map +1 -0
- package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
- package/dist/chunk-U3J4UPV3.js.map +1 -0
- package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
- package/dist/chunk-VE2PNSDQ.js.map +1 -0
- package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
- package/dist/chunk-WAFHC4DQ.js.map +1 -0
- package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
- package/dist/chunk-YJ67WYVW.js.map +1 -0
- package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
- package/dist/chunk-Z427NPMU.js.map +1 -0
- package/dist/core/index.cjs +4 -4
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/data-display/index.cjs.map +1 -1
- package/dist/data-display/index.js +5 -5
- package/dist/feedback/index.cjs.map +1 -1
- package/dist/feedback/index.js +2 -2
- package/dist/forms/index.cjs.map +1 -1
- package/dist/forms/index.js +8 -8
- package/dist/glass/index.cjs.map +1 -1
- package/dist/glass/index.js +2 -2
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -19
- package/dist/layout/index.cjs.map +1 -1
- package/dist/layout/index.js +2 -2
- package/dist/navigation/index.cjs.map +1 -1
- package/dist/navigation/index.js +6 -6
- package/dist/overlays/index.cjs.map +1 -1
- package/dist/overlays/index.js +7 -7
- package/dist/reset.css +45 -45
- package/dist/tokens.css +11 -11
- package/dist/typography/index.cjs.map +1 -1
- package/dist/typography/index.js +4 -4
- package/llms.txt +3 -3
- package/package.json +165 -165
- package/scripts/postinstall.js +125 -125
- package/src/bento/BentoCard.tsx +140 -140
- package/src/bento/BentoGrid.tsx +50 -50
- package/src/bento/index.ts +2 -2
- package/src/bento/tokens.css +46 -46
- package/src/buttons/Button.tsx +104 -104
- package/src/buttons/ButtonGroup.tsx +43 -43
- package/src/buttons/IconButton.tsx +79 -79
- package/src/buttons/SplitButton.tsx +186 -186
- package/src/buttons/ToggleButton.tsx +157 -157
- package/src/buttons/index.ts +5 -5
- package/src/buttons/tokens.css +6 -6
- package/src/core/ThemeProvider.tsx +133 -133
- package/src/core/index.ts +2 -2
- package/src/core/tokens.css +134 -134
- package/src/data-display/Accordion.tsx +147 -147
- package/src/data-display/Avatar.tsx +134 -134
- package/src/data-display/Card.tsx +112 -112
- package/src/data-display/Chip.tsx +74 -74
- package/src/data-display/DataTablePro.tsx +534 -534
- package/src/data-display/StatCard.tsx +60 -60
- package/src/data-display/Table.tsx +74 -74
- package/src/data-display/Timeline.tsx +62 -62
- package/src/data-display/TreeView.tsx +279 -279
- package/src/data-display/tokens.css +4 -4
- package/src/feedback/Alert.tsx +80 -80
- package/src/feedback/EmptyState.tsx +45 -45
- package/src/feedback/Skeleton.tsx +66 -66
- package/src/feedback/Spinner.tsx +65 -65
- package/src/feedback/index.ts +5 -5
- package/src/feedback/tokens.css +4 -4
- package/src/forms/Checkbox.tsx +149 -149
- package/src/forms/CheckboxGroup.tsx +76 -76
- package/src/forms/Combobox.tsx +512 -512
- package/src/forms/DatePicker.tsx +784 -784
- package/src/forms/FileUpload.tsx +266 -266
- package/src/forms/FormField.tsx +115 -115
- package/src/forms/Input.tsx +151 -151
- package/src/forms/NumberInput.tsx +262 -262
- package/src/forms/PasswordInput.tsx +35 -35
- package/src/forms/PinInput.tsx +139 -139
- package/src/forms/Radio.tsx +142 -142
- package/src/forms/RangeSlider.tsx +237 -237
- package/src/forms/SearchInput.tsx +34 -34
- package/src/forms/Select.tsx +103 -103
- package/src/forms/Slider.tsx +85 -85
- package/src/forms/Textarea.tsx +114 -114
- package/src/forms/tokens.css +6 -6
- package/src/glass/GlassButton.tsx +59 -59
- package/src/glass/GlassCard.tsx +71 -71
- package/src/glass/index.ts +3 -3
- package/src/glass/tokens.css +81 -81
- package/src/index.ts +26 -26
- package/src/layout/AspectRatio.tsx +39 -39
- package/src/layout/Container.tsx +53 -53
- package/src/layout/Divider.tsx +61 -61
- package/src/layout/Flex.tsx +79 -79
- package/src/layout/Grid.tsx +124 -124
- package/src/layout/PageLayout.tsx +59 -59
- package/src/layout/Section.tsx +47 -47
- package/src/layout/Spacer.tsx +30 -30
- package/src/layout/Stack.tsx +88 -88
- package/src/layout/tokens.css +8 -8
- package/src/navigation/Breadcrumb.tsx +73 -73
- package/src/navigation/CommandPalette.tsx +142 -142
- package/src/navigation/DropdownMenu.tsx +244 -244
- package/src/navigation/Pagination.tsx +114 -114
- package/src/navigation/Stepper.tsx +93 -93
- package/src/navigation/Tabs.tsx +265 -265
- package/src/navigation/index.ts +6 -6
- package/src/navigation/tokens.css +4 -4
- package/src/overlays/AlertDialog.tsx +63 -63
- package/src/overlays/BottomSheet.tsx +143 -143
- package/src/overlays/ContextMenu.tsx +161 -161
- package/src/overlays/Drawer.tsx +160 -160
- package/src/overlays/FocusTrap.tsx +122 -122
- package/src/overlays/Modal.tsx +296 -296
- package/src/overlays/Popover.tsx +156 -156
- package/src/overlays/Portal.tsx +23 -23
- package/src/overlays/Toast.tsx +175 -175
- package/src/overlays/Tooltip.tsx +125 -125
- package/src/overlays/tokens.css +8 -8
- package/src/registry/components.ts +1368 -1368
- package/src/styles/components.css +39 -39
- package/src/styles/index.css +5 -5
- package/src/styles/reset.css +45 -45
- package/src/styles/tokens.css +11 -11
- package/src/typography/Blockquote.tsx +32 -32
- package/src/typography/Code.tsx +89 -89
- package/src/typography/Heading.tsx +56 -56
- package/src/typography/Kbd.tsx +25 -25
- package/src/typography/Label.tsx +47 -47
- package/src/typography/Link.tsx +56 -56
- package/src/typography/Text.tsx +62 -62
- package/src/typography/index.ts +7 -7
- package/src/typography/tokens.css +5 -5
- package/src/utils/cn.ts +6 -6
- package/src/utils/scroll-lock.ts +19 -19
- package/src/utils/useControllableState.ts +54 -54
- package/ui-contract.json +1 -1
- package/dist/chunk-2YJBW4E2.js.map +0 -1
- package/dist/chunk-4CRFNALF.js.map +0 -1
- package/dist/chunk-4MS6ZHFQ.js.map +0 -1
- package/dist/chunk-77KTYOMQ.js.map +0 -1
- package/dist/chunk-ADO3SVUA.js.map +0 -1
- package/dist/chunk-D7FB3TCA.js.map +0 -1
- package/dist/chunk-DSEL7ELZ.js.map +0 -1
- package/dist/chunk-EXFR2BFJ.js.map +0 -1
- package/dist/chunk-GXWB6BSY.js.map +0 -1
- package/dist/chunk-I2FQBMVW.js.map +0 -1
- package/dist/chunk-I5O2WCGF.js.map +0 -1
- package/dist/chunk-ME4XILG5.js.map +0 -1
- package/dist/chunk-PC5SZFZC.js.map +0 -1
- package/dist/chunk-S6XJMYME.js.map +0 -1
- package/dist/chunk-V52JOSDF.js.map +0 -1
- package/dist/chunk-W5WNWTQC.js.map +0 -1
- package/dist/chunk-YJ64RI7J.js.map +0 -1
- package/dist/chunk-YOCGBORV.js.map +0 -1
- package/dist/chunk-ZEDXARXW.js.map +0 -1
package/src/forms/DatePicker.tsx
CHANGED
|
@@ -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';
|