@thangnqdev/ui 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -206
- package/dist/bento/index.cjs.map +1 -1
- package/dist/bento/index.js +2 -2
- package/dist/bento/styles.css +2 -46
- package/dist/buttons/index.cjs +111 -18
- package/dist/buttons/index.cjs.map +1 -1
- package/dist/buttons/index.d.cts +1 -1
- package/dist/buttons/index.d.ts +1 -1
- package/dist/buttons/index.js +3 -3
- package/dist/buttons/styles.css +2 -6
- package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
- package/dist/chunk-2YJBW4E2.js.map +1 -0
- package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
- package/dist/chunk-4CRFNALF.js.map +1 -0
- package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
- package/dist/chunk-4MS6ZHFQ.js.map +1 -0
- package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
- package/dist/chunk-77KTYOMQ.js.map +1 -0
- package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
- package/dist/chunk-ADO3SVUA.js.map +1 -0
- package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
- package/dist/chunk-D7FB3TCA.js.map +1 -0
- package/dist/chunk-DSEL7ELZ.js +314 -0
- package/dist/chunk-DSEL7ELZ.js.map +1 -0
- package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
- package/dist/chunk-EXFR2BFJ.js.map +1 -0
- package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
- package/dist/chunk-GXWB6BSY.js.map +1 -0
- package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
- package/dist/chunk-I2FQBMVW.js.map +1 -0
- package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
- package/dist/chunk-I5O2WCGF.js.map +1 -0
- package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
- package/dist/chunk-ME4XILG5.js.map +1 -0
- package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
- package/dist/chunk-PC5SZFZC.js.map +1 -0
- package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
- package/dist/chunk-S6XJMYME.js.map +1 -0
- package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
- package/dist/chunk-V52JOSDF.js.map +1 -0
- package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
- package/dist/chunk-W5WNWTQC.js.map +1 -0
- package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
- package/dist/chunk-YJ64RI7J.js.map +1 -0
- package/dist/chunk-YOCGBORV.js +155 -0
- package/dist/chunk-YOCGBORV.js.map +1 -0
- package/dist/chunk-ZEDXARXW.js +96 -0
- package/dist/chunk-ZEDXARXW.js.map +1 -0
- package/dist/components.css +2 -0
- package/dist/core/index.cjs +33 -24
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/core/styles.css +2 -134
- package/dist/data-display/index.cjs +770 -247
- package/dist/data-display/index.cjs.map +1 -1
- package/dist/data-display/index.d.cts +1 -1
- package/dist/data-display/index.d.ts +1 -1
- package/dist/data-display/index.js +5 -4
- package/dist/data-display/styles.css +2 -4
- package/dist/feedback/index.cjs.map +1 -1
- package/dist/feedback/index.js +2 -2
- package/dist/feedback/styles.css +2 -4
- package/dist/forms/index.cjs +1211 -459
- package/dist/forms/index.cjs.map +1 -1
- package/dist/forms/index.d.cts +1 -1
- package/dist/forms/index.d.ts +1 -1
- package/dist/forms/index.js +8 -8
- package/dist/forms/styles.css +2 -6
- package/dist/glass/index.cjs.map +1 -1
- package/dist/glass/index.js +2 -2
- package/dist/glass/styles.css +2 -81
- package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
- package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
- package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
- package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
- package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
- package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
- package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
- package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
- package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
- package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
- package/dist/index.cjs +2481 -917
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +19 -19
- package/dist/layout/index.cjs.map +1 -1
- package/dist/layout/index.js +2 -2
- package/dist/layout/styles.css +2 -8
- package/dist/navigation/index.cjs +288 -48
- package/dist/navigation/index.cjs.map +1 -1
- package/dist/navigation/index.d.cts +1 -1
- package/dist/navigation/index.d.ts +1 -1
- package/dist/navigation/index.js +6 -6
- package/dist/navigation/styles.css +2 -4
- package/dist/overlays/index.cjs +566 -229
- package/dist/overlays/index.cjs.map +1 -1
- package/dist/overlays/index.d.cts +1 -1
- package/dist/overlays/index.d.ts +1 -1
- package/dist/overlays/index.js +7 -7
- package/dist/overlays/styles.css +2 -8
- package/dist/reset.css +45 -0
- package/dist/styles.css +1 -1
- package/dist/tokens.css +11 -0
- package/dist/typography/index.cjs.map +1 -1
- package/dist/typography/index.js +4 -4
- package/dist/typography/styles.css +2 -5
- package/llms.txt +211 -113
- package/package.json +160 -116
- 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 -86
- 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 +135 -123
- 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 +299 -121
- 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 +243 -104
- 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 -69
- package/src/forms/CheckboxGroup.tsx +76 -29
- package/src/forms/Combobox.tsx +172 -28
- package/src/forms/DatePicker.tsx +385 -81
- package/src/forms/FileUpload.tsx +51 -7
- package/src/forms/FormField.tsx +115 -115
- package/src/forms/Input.tsx +151 -151
- package/src/forms/NumberInput.tsx +34 -20
- package/src/forms/PasswordInput.tsx +35 -35
- package/src/forms/PinInput.tsx +139 -125
- package/src/forms/Radio.tsx +142 -142
- package/src/forms/RangeSlider.tsx +237 -111
- 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 -160
- package/src/navigation/Pagination.tsx +114 -114
- package/src/navigation/Stepper.tsx +93 -93
- package/src/navigation/Tabs.tsx +265 -133
- package/src/navigation/index.ts +6 -6
- package/src/navigation/tokens.css +4 -4
- package/src/overlays/AlertDialog.tsx +63 -54
- package/src/overlays/BottomSheet.tsx +30 -10
- package/src/overlays/ContextMenu.tsx +51 -9
- package/src/overlays/Drawer.tsx +160 -145
- package/src/overlays/FocusTrap.tsx +122 -49
- package/src/overlays/Modal.tsx +296 -219
- package/src/overlays/Popover.tsx +55 -25
- package/src/overlays/Portal.tsx +23 -23
- package/src/overlays/Toast.tsx +175 -137
- package/src/overlays/Tooltip.tsx +125 -100
- package/src/overlays/tokens.css +8 -8
- package/src/registry/components.ts +1368 -0
- package/src/styles/components.css +39 -0
- package/src/styles/index.css +2 -93
- package/src/styles/reset.css +45 -0
- package/src/styles/tokens.css +11 -2
- 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 -0
- package/src/utils/useControllableState.ts +54 -0
- package/ui-contract.json +192 -35
- package/dist/chunk-2OXZK6IP.js.map +0 -1
- package/dist/chunk-4S6L5V7X.js.map +0 -1
- package/dist/chunk-5FZ6ZFII.js.map +0 -1
- package/dist/chunk-66VWUJEI.js.map +0 -1
- package/dist/chunk-A7P3O5Z5.js.map +0 -1
- package/dist/chunk-ATYGDTMK.js.map +0 -1
- package/dist/chunk-C4SUXMI3.js.map +0 -1
- package/dist/chunk-EU2LPUYP.js.map +0 -1
- package/dist/chunk-F5Q7BTMH.js.map +0 -1
- package/dist/chunk-GXI2H5XA.js.map +0 -1
- package/dist/chunk-H3LCNPMC.js +0 -101
- package/dist/chunk-H3LCNPMC.js.map +0 -1
- package/dist/chunk-IABPUCAQ.js.map +0 -1
- package/dist/chunk-KJRBRTQE.js.map +0 -1
- package/dist/chunk-LBUBXUYQ.js +0 -40
- package/dist/chunk-LBUBXUYQ.js.map +0 -1
- package/dist/chunk-LWFRUJRU.js.map +0 -1
- package/dist/chunk-NFVONJUK.js.map +0 -1
- package/dist/chunk-PF4ZHGVW.js.map +0 -1
- package/dist/chunk-WAFHC4DQ.js.map +0 -1
- package/dist/chunk-X76KBH4Y.js +0 -71
- package/dist/chunk-X76KBH4Y.js.map +0 -1
package/src/forms/DatePicker.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import React, { forwardRef, useState, useRef, useEffect } from 'react';
|
|
2
2
|
import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
|
3
3
|
import { cn } from '../utils/cn';
|
|
4
|
-
import { Button } from '../buttons/Button';
|
|
5
4
|
import { Popover } from '../overlays/Popover';
|
|
5
|
+
import { useControllableState } from '../utils/useControllableState';
|
|
6
6
|
|
|
7
7
|
// Utility helper to format date as YYYY-MM-DD
|
|
8
8
|
export function formatDate(d: Date | null | undefined): string {
|
|
@@ -35,7 +35,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|
|
35
35
|
(
|
|
36
36
|
{
|
|
37
37
|
value: controlledValue,
|
|
38
|
-
defaultValue,
|
|
38
|
+
defaultValue = null,
|
|
39
39
|
onChange,
|
|
40
40
|
placeholder = 'Select date...',
|
|
41
41
|
minDate,
|
|
@@ -47,30 +47,32 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|
|
47
47
|
},
|
|
48
48
|
ref
|
|
49
49
|
) => {
|
|
50
|
-
const [
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
const [selectedDate, setSelectedDate] = useControllableState<Date | null>({
|
|
51
|
+
value: controlledValue,
|
|
52
|
+
defaultValue,
|
|
53
|
+
onChange,
|
|
54
|
+
name: 'DatePicker',
|
|
55
|
+
});
|
|
54
56
|
|
|
55
57
|
const [viewDate, setViewDate] = useState<Date>(selectedDate || new Date());
|
|
56
58
|
const [isOpen, setIsOpen] = useState(false);
|
|
59
|
+
const [focusedDate, setFocusedDate] = useState<Date>(selectedDate || new Date());
|
|
57
60
|
|
|
58
61
|
useEffect(() => {
|
|
59
62
|
if (selectedDate) {
|
|
60
63
|
setViewDate(selectedDate);
|
|
64
|
+
setFocusedDate(selectedDate);
|
|
61
65
|
}
|
|
62
66
|
}, [selectedDate]);
|
|
63
67
|
|
|
64
68
|
const handleSelectDate = (d: Date) => {
|
|
65
|
-
|
|
66
|
-
onChange?.(d);
|
|
69
|
+
setSelectedDate(d);
|
|
67
70
|
setIsOpen(false);
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
const handleClear = (e: React.MouseEvent) => {
|
|
71
74
|
e.stopPropagation();
|
|
72
|
-
|
|
73
|
-
onChange?.(null);
|
|
75
|
+
setSelectedDate(null);
|
|
74
76
|
};
|
|
75
77
|
|
|
76
78
|
const prevMonth = () => {
|
|
@@ -115,22 +117,74 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|
|
115
117
|
|
|
116
118
|
const todayStr = formatDate(new Date());
|
|
117
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
|
+
};
|
|
118
170
|
|
|
119
171
|
return (
|
|
120
172
|
<div ref={ref} className={cn('relative inline-block w-full', className)}>
|
|
121
173
|
<Popover
|
|
122
|
-
|
|
123
|
-
|
|
174
|
+
open={isOpen}
|
|
175
|
+
onOpenChange={setIsOpen}
|
|
124
176
|
trigger={
|
|
125
177
|
<button
|
|
126
178
|
type="button"
|
|
127
179
|
disabled={disabled}
|
|
128
|
-
|
|
180
|
+
aria-haspopup="dialog"
|
|
181
|
+
aria-expanded={isOpen}
|
|
182
|
+
aria-label={selectedDate ? `Selected date: ${formatDisplayDate(selectedDate)}` : placeholder}
|
|
129
183
|
className={cn(
|
|
130
|
-
'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',
|
|
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)]',
|
|
131
185
|
disabled
|
|
132
186
|
? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
|
|
133
|
-
: 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]
|
|
187
|
+
: 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
|
|
134
188
|
)}
|
|
135
189
|
>
|
|
136
190
|
<div className="flex items-center gap-2.5 truncate">
|
|
@@ -143,7 +197,11 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|
|
143
197
|
<div className="flex items-center gap-1 text-[var(--ui-text-muted)]">
|
|
144
198
|
{clearable && selectedDate && !disabled && (
|
|
145
199
|
<span
|
|
200
|
+
role="button"
|
|
201
|
+
tabIndex={0}
|
|
202
|
+
aria-label="Clear date"
|
|
146
203
|
onClick={handleClear}
|
|
204
|
+
onKeyDown={(e) => e.key === 'Enter' && handleClear(e as unknown as React.MouseEvent)}
|
|
147
205
|
className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
|
|
148
206
|
>
|
|
149
207
|
<X className="w-3.5 h-3.5" />
|
|
@@ -153,24 +211,35 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|
|
153
211
|
</button>
|
|
154
212
|
}
|
|
155
213
|
>
|
|
156
|
-
<div
|
|
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
|
+
>
|
|
157
220
|
{/* Header: Month Year + Prev/Next */}
|
|
158
221
|
<div className="flex items-center justify-between">
|
|
159
|
-
<span
|
|
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
|
+
>
|
|
160
227
|
{viewDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
|
|
161
228
|
</span>
|
|
162
229
|
<div className="flex items-center gap-1">
|
|
163
230
|
<button
|
|
164
231
|
type="button"
|
|
165
232
|
onClick={prevMonth}
|
|
166
|
-
|
|
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"
|
|
167
235
|
>
|
|
168
236
|
<ChevronLeft className="w-4 h-4" />
|
|
169
237
|
</button>
|
|
170
238
|
<button
|
|
171
239
|
type="button"
|
|
172
240
|
onClick={nextMonth}
|
|
173
|
-
|
|
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"
|
|
174
243
|
>
|
|
175
244
|
<ChevronRight className="w-4 h-4" />
|
|
176
245
|
</button>
|
|
@@ -185,7 +254,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|
|
185
254
|
key={idx}
|
|
186
255
|
type="button"
|
|
187
256
|
onClick={() => handleSelectDate(preset.date)}
|
|
188
|
-
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"
|
|
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"
|
|
189
258
|
>
|
|
190
259
|
{preset.label}
|
|
191
260
|
</button>
|
|
@@ -194,31 +263,60 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|
|
194
263
|
)}
|
|
195
264
|
|
|
196
265
|
{/* Weekday headers */}
|
|
197
|
-
<div
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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}
|
|
201
281
|
</div>
|
|
202
282
|
))}
|
|
203
283
|
</div>
|
|
204
284
|
|
|
205
285
|
{/* Day grid */}
|
|
206
|
-
<div
|
|
286
|
+
<div
|
|
287
|
+
role="grid"
|
|
288
|
+
aria-labelledby="calendar-month-heading"
|
|
289
|
+
className="grid grid-cols-7 gap-1"
|
|
290
|
+
>
|
|
207
291
|
{days.map((item, idx) => {
|
|
208
292
|
const itemStr = formatDate(item.date);
|
|
209
293
|
const isSelected = itemStr === selectedStr;
|
|
210
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
|
+
});
|
|
211
303
|
|
|
212
304
|
return (
|
|
213
305
|
<button
|
|
214
306
|
key={idx}
|
|
215
307
|
type="button"
|
|
308
|
+
role="gridcell"
|
|
216
309
|
disabled={item.disabled}
|
|
310
|
+
aria-selected={isSelected}
|
|
311
|
+
aria-current={isToday ? 'date' : undefined}
|
|
312
|
+
aria-label={fullDateLabel}
|
|
313
|
+
tabIndex={isFocused ? 0 : -1}
|
|
217
314
|
onClick={() => handleSelectDate(item.date)}
|
|
218
315
|
className={cn(
|
|
219
|
-
'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer',
|
|
316
|
+
'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer outline-none',
|
|
220
317
|
item.disabled && 'opacity-25 cursor-not-allowed pointer-events-none',
|
|
221
318
|
!item.currentMonth && 'text-[var(--ui-text-muted)] opacity-50',
|
|
319
|
+
isFocused && 'ring-2 ring-[var(--ui-primary)]',
|
|
222
320
|
isSelected
|
|
223
321
|
? 'bg-[var(--ui-primary)] text-white font-bold shadow-md shadow-blue-500/30'
|
|
224
322
|
: isToday
|
|
@@ -257,11 +355,14 @@ export interface DateRangePickerProps {
|
|
|
257
355
|
}
|
|
258
356
|
|
|
259
357
|
export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
|
|
260
|
-
({ value: controlledVal, defaultValue, onChange, placeholder = 'Select date range...', className, disabled = false }, ref) => {
|
|
261
|
-
const [
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
|
|
265
366
|
const [viewDate, setViewDate] = useState<Date>(range.from || new Date());
|
|
266
367
|
const [isOpen, setIsOpen] = useState(false);
|
|
267
368
|
|
|
@@ -277,8 +378,7 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
|
|
|
277
378
|
setIsOpen(false);
|
|
278
379
|
}
|
|
279
380
|
}
|
|
280
|
-
|
|
281
|
-
onChange?.(next);
|
|
381
|
+
setRange(next);
|
|
282
382
|
};
|
|
283
383
|
|
|
284
384
|
const displayText =
|
|
@@ -302,14 +402,16 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
|
|
|
302
402
|
return (
|
|
303
403
|
<div ref={ref} className={cn('relative inline-block w-full', className)}>
|
|
304
404
|
<Popover
|
|
305
|
-
|
|
306
|
-
|
|
405
|
+
open={isOpen}
|
|
406
|
+
onOpenChange={setIsOpen}
|
|
307
407
|
trigger={
|
|
308
408
|
<button
|
|
309
409
|
type="button"
|
|
310
410
|
disabled={disabled}
|
|
311
|
-
|
|
312
|
-
|
|
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)]"
|
|
313
415
|
>
|
|
314
416
|
<div className="flex items-center gap-2.5 truncate">
|
|
315
417
|
<CalendarIcon className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
|
|
@@ -320,30 +422,40 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
|
|
|
320
422
|
</button>
|
|
321
423
|
}
|
|
322
424
|
>
|
|
323
|
-
<div
|
|
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
|
+
>
|
|
324
430
|
<div className="flex items-center justify-between">
|
|
325
|
-
<span className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider">
|
|
431
|
+
<span id="range-month-heading" className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider">
|
|
326
432
|
{viewDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
|
|
327
433
|
</span>
|
|
328
434
|
<div className="flex items-center gap-1">
|
|
329
435
|
<button
|
|
330
436
|
type="button"
|
|
331
437
|
onClick={() => setViewDate(new Date(year, month - 1, 1))}
|
|
332
|
-
|
|
438
|
+
aria-label="Previous month"
|
|
439
|
+
className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] cursor-pointer"
|
|
333
440
|
>
|
|
334
441
|
<ChevronLeft className="w-4 h-4" />
|
|
335
442
|
</button>
|
|
336
443
|
<button
|
|
337
444
|
type="button"
|
|
338
445
|
onClick={() => setViewDate(new Date(year, month + 1, 1))}
|
|
339
|
-
|
|
446
|
+
aria-label="Next month"
|
|
447
|
+
className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] cursor-pointer"
|
|
340
448
|
>
|
|
341
449
|
<ChevronRight className="w-4 h-4" />
|
|
342
450
|
</button>
|
|
343
451
|
</div>
|
|
344
452
|
</div>
|
|
345
453
|
|
|
346
|
-
<div
|
|
454
|
+
<div
|
|
455
|
+
role="grid"
|
|
456
|
+
aria-labelledby="range-month-heading"
|
|
457
|
+
className="grid grid-cols-7 gap-1 text-center"
|
|
458
|
+
>
|
|
347
459
|
{Array.from({ length: firstDayIndex }).map((_, i) => (
|
|
348
460
|
<div key={`empty-${i}`} />
|
|
349
461
|
))}
|
|
@@ -360,6 +472,9 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
|
|
|
360
472
|
<button
|
|
361
473
|
key={idx}
|
|
362
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' })}
|
|
363
478
|
onClick={() => handleDayClick(d)}
|
|
364
479
|
className={cn(
|
|
365
480
|
'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer',
|
|
@@ -391,59 +506,202 @@ export interface TimePickerProps {
|
|
|
391
506
|
format?: '12h' | '24h';
|
|
392
507
|
className?: string;
|
|
393
508
|
disabled?: boolean;
|
|
509
|
+
placeholder?: string;
|
|
394
510
|
}
|
|
395
511
|
|
|
396
512
|
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|
397
|
-
(
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
});
|
|
400
534
|
|
|
401
|
-
const [hours, setHours] = useState(time.split(':')[0] || '12');
|
|
402
|
-
const [minutes, setMinutes] = useState(time.split(':')[1] || '00');
|
|
403
535
|
const [isOpen, setIsOpen] = useState(false);
|
|
404
536
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
+
}
|
|
411
647
|
};
|
|
412
648
|
|
|
413
649
|
return (
|
|
414
650
|
<div ref={ref} className={cn('relative inline-block w-full', className)}>
|
|
415
651
|
<Popover
|
|
416
|
-
|
|
417
|
-
|
|
652
|
+
open={isOpen}
|
|
653
|
+
onOpenChange={setIsOpen}
|
|
418
654
|
trigger={
|
|
419
655
|
<button
|
|
420
656
|
type="button"
|
|
421
657
|
disabled={disabled}
|
|
422
|
-
|
|
423
|
-
|
|
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
|
+
)}
|
|
424
665
|
>
|
|
425
666
|
<div className="flex items-center gap-2.5">
|
|
426
|
-
<Clock className="w-4 h-4 text-[var(--ui-text-muted)]" />
|
|
427
|
-
<span className="font-mono text-sm font-semibold text-[var(--ui-text-primary)]">
|
|
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>
|
|
428
671
|
</div>
|
|
429
672
|
</button>
|
|
430
673
|
}
|
|
431
674
|
>
|
|
432
|
-
<div
|
|
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
|
+
>
|
|
433
680
|
<span className="text-xs font-bold text-[var(--ui-text-muted)] uppercase tracking-wider font-mono">
|
|
434
681
|
Select Time ({format})
|
|
435
682
|
</span>
|
|
436
683
|
<div className="flex items-center justify-center gap-2">
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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;
|
|
440
695
|
return (
|
|
441
696
|
<div
|
|
442
697
|
key={h}
|
|
443
|
-
|
|
698
|
+
tabIndex={isSelected ? 0 : -1}
|
|
699
|
+
role="option"
|
|
700
|
+
aria-selected={isSelected}
|
|
701
|
+
onClick={() => updateTime(h, minutes, period)}
|
|
444
702
|
className={cn(
|
|
445
|
-
'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors',
|
|
446
|
-
|
|
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
|
|
447
705
|
? 'bg-[var(--ui-primary)] text-white'
|
|
448
706
|
: 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
|
|
449
707
|
)}
|
|
@@ -454,22 +712,68 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|
|
454
712
|
})}
|
|
455
713
|
</div>
|
|
456
714
|
<span className="text-sm font-bold">:</span>
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
+
})}
|
|
472
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
|
+
)}
|
|
473
777
|
</div>
|
|
474
778
|
</div>
|
|
475
779
|
</Popover>
|