@sixthshift/design-system 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/dist/styles.css +2 -0
  4. package/dist/theme.generated.css +438 -0
  5. package/package.json +238 -0
  6. package/src/components/Avatar/Avatar.tsx +19 -0
  7. package/src/components/Avatar/index.ts +1 -0
  8. package/src/components/Badge/Badge.tsx +59 -0
  9. package/src/components/Badge/index.ts +1 -0
  10. package/src/components/BarChart/BarChart.tsx +51 -0
  11. package/src/components/BarChart/index.ts +1 -0
  12. package/src/components/Breadcrumb/Breadcrumb.tsx +47 -0
  13. package/src/components/Breadcrumb/index.ts +5 -0
  14. package/src/components/Button/Button.tsx +157 -0
  15. package/src/components/Button/index.ts +1 -0
  16. package/src/components/Calendar/Calendar.tsx +433 -0
  17. package/src/components/Calendar/calendar.hooks.ts +329 -0
  18. package/src/components/Calendar/calendar.types.ts +117 -0
  19. package/src/components/Calendar/index.ts +28 -0
  20. package/src/components/Card/Card.tsx +47 -0
  21. package/src/components/Card/index.ts +1 -0
  22. package/src/components/Checkbox/Checkbox.tsx +144 -0
  23. package/src/components/Checkbox/index.ts +1 -0
  24. package/src/components/CheckboxGroup/CheckboxGroup.tsx +173 -0
  25. package/src/components/CheckboxGroup/index.ts +5 -0
  26. package/src/components/Code/Editor/Editor.tsx +161 -0
  27. package/src/components/Code/Editor/index.ts +2 -0
  28. package/src/components/Code/Editor/semanticTokenProvider.ts +69 -0
  29. package/src/components/Code/Editor/themes.ts +112 -0
  30. package/src/components/Code/ValidationStatus/ValidationStatus.tsx +81 -0
  31. package/src/components/Code/ValidationStatus/index.ts +1 -0
  32. package/src/components/Code/Workspace/StatusBar.tsx +47 -0
  33. package/src/components/Code/Workspace/Toolbar.tsx +47 -0
  34. package/src/components/Code/Workspace/Workspace.tsx +109 -0
  35. package/src/components/Code/Workspace/index.ts +3 -0
  36. package/src/components/ColorDot/ColorDot.tsx +50 -0
  37. package/src/components/ColorDot/index.ts +1 -0
  38. package/src/components/DatePicker/DatePicker.tsx +236 -0
  39. package/src/components/DatePicker/datepicker.hooks.ts +381 -0
  40. package/src/components/DatePicker/datepicker.types.ts +140 -0
  41. package/src/components/DatePicker/index.ts +12 -0
  42. package/src/components/DateRangePicker/DateRangePicker.tsx +127 -0
  43. package/src/components/DateRangePicker/daterangepicker.types.ts +53 -0
  44. package/src/components/DateRangePicker/index.ts +2 -0
  45. package/src/components/DateTimePicker/DateTimePicker.tsx +399 -0
  46. package/src/components/DateTimePicker/datetimepicker.types.ts +48 -0
  47. package/src/components/DateTimePicker/index.ts +2 -0
  48. package/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +679 -0
  49. package/src/components/DateTimeRangePicker/datetimerangepicker.types.ts +62 -0
  50. package/src/components/DateTimeRangePicker/index.ts +2 -0
  51. package/src/components/EmptyState/EmptyState.tsx +29 -0
  52. package/src/components/EmptyState/index.ts +1 -0
  53. package/src/components/Field/Field.tsx +60 -0
  54. package/src/components/Field/index.ts +1 -0
  55. package/src/components/FormField/FormField.tsx +55 -0
  56. package/src/components/FormField/index.ts +2 -0
  57. package/src/components/HeatMap/HeatMapCalendar.tsx +127 -0
  58. package/src/components/HeatMap/HeatMapMatrix.tsx +75 -0
  59. package/src/components/HeatMap/heat-map.utils.ts +15 -0
  60. package/src/components/HeatMap/index.ts +3 -0
  61. package/src/components/HoverCard/HoverCard.tsx +83 -0
  62. package/src/components/HoverCard/components/HoverCardContent.tsx +30 -0
  63. package/src/components/HoverCard/components/HoverCardContext.tsx +22 -0
  64. package/src/components/HoverCard/components/HoverCardTrigger.tsx +32 -0
  65. package/src/components/HoverCard/index.ts +3 -0
  66. package/src/components/Input/Input.tsx +48 -0
  67. package/src/components/Input/index.ts +1 -0
  68. package/src/components/LineChart/LineChart.tsx +251 -0
  69. package/src/components/LineChart/index.ts +1 -0
  70. package/src/components/Markdown/Markdown.tsx +40 -0
  71. package/src/components/Markdown/index.ts +1 -0
  72. package/src/components/Message/Message.tsx +59 -0
  73. package/src/components/Message/components/MessageBody.tsx +9 -0
  74. package/src/components/Message/components/MessageDescription.tsx +9 -0
  75. package/src/components/Message/components/MessageIcon.tsx +13 -0
  76. package/src/components/Message/components/MessageTitle.tsx +9 -0
  77. package/src/components/Message/index.ts +10 -0
  78. package/src/components/MetricList/MetricList.tsx +13 -0
  79. package/src/components/MetricList/MetricRow.tsx +30 -0
  80. package/src/components/MetricList/index.ts +2 -0
  81. package/src/components/Modal/Modal.tsx +146 -0
  82. package/src/components/Modal/components/ModalBody.tsx +9 -0
  83. package/src/components/Modal/components/ModalContext.tsx +27 -0
  84. package/src/components/Modal/components/ModalFooter.tsx +9 -0
  85. package/src/components/Modal/components/ModalHeader.tsx +29 -0
  86. package/src/components/Modal/components/index.ts +9 -0
  87. package/src/components/Modal/index.ts +13 -0
  88. package/src/components/NavBottom/NavBottom.tsx +53 -0
  89. package/src/components/NavBottom/index.ts +3 -0
  90. package/src/components/NavSide/NavItem.tsx +39 -0
  91. package/src/components/NavSide/NavSide.tsx +40 -0
  92. package/src/components/NavSide/index.ts +2 -0
  93. package/src/components/NavSide/types.ts +36 -0
  94. package/src/components/Pagination/Pagination.tsx +86 -0
  95. package/src/components/Pagination/index.ts +2 -0
  96. package/src/components/Popover/Popover.tsx +68 -0
  97. package/src/components/Popover/components/PopoverBody.tsx +32 -0
  98. package/src/components/Popover/components/PopoverClose.tsx +26 -0
  99. package/src/components/Popover/components/PopoverContext.tsx +24 -0
  100. package/src/components/Popover/components/PopoverTrigger.tsx +31 -0
  101. package/src/components/Popover/index.ts +10 -0
  102. package/src/components/ProgressBar/ProgressBar.tsx +42 -0
  103. package/src/components/ProgressBar/index.ts +1 -0
  104. package/src/components/RadioButton/RadioButton.tsx +89 -0
  105. package/src/components/RadioButton/index.ts +1 -0
  106. package/src/components/RadioButtonGroup/RadioButtonGroup.tsx +193 -0
  107. package/src/components/RadioButtonGroup/index.ts +5 -0
  108. package/src/components/SearchInput/SearchInput.tsx +45 -0
  109. package/src/components/SearchInput/index.ts +2 -0
  110. package/src/components/Select/Select.tsx +289 -0
  111. package/src/components/Select/SelectDropdown.tsx +90 -0
  112. package/src/components/Select/SelectTrigger.tsx +130 -0
  113. package/src/components/Select/index.ts +3 -0
  114. package/src/components/Select/useSelectKeyboard.ts +111 -0
  115. package/src/components/Separator/Separator.tsx +20 -0
  116. package/src/components/Separator/index.ts +1 -0
  117. package/src/components/Sheet/Sheet.tsx +136 -0
  118. package/src/components/Sheet/components/SheetBody.tsx +9 -0
  119. package/src/components/Sheet/components/SheetContext.tsx +23 -0
  120. package/src/components/Sheet/components/SheetFooter.tsx +9 -0
  121. package/src/components/Sheet/components/SheetHeader.tsx +29 -0
  122. package/src/components/Sheet/components/index.ts +9 -0
  123. package/src/components/Sheet/index.ts +13 -0
  124. package/src/components/Skeleton/Skeleton.tsx +7 -0
  125. package/src/components/Skeleton/index.ts +1 -0
  126. package/src/components/Sparkline/Sparkline.tsx +93 -0
  127. package/src/components/Sparkline/index.ts +1 -0
  128. package/src/components/Spinner/Spinner.tsx +41 -0
  129. package/src/components/Spinner/index.ts +1 -0
  130. package/src/components/StatsCard/StatsCard.tsx +40 -0
  131. package/src/components/StatsCard/index.ts +1 -0
  132. package/src/components/Switch/Switch.tsx +128 -0
  133. package/src/components/Switch/index.ts +1 -0
  134. package/src/components/Tabs/Tabs.tsx +49 -0
  135. package/src/components/Tabs/components/TabsContext.tsx +35 -0
  136. package/src/components/Tabs/components/TabsList.tsx +98 -0
  137. package/src/components/Tabs/components/TabsPanels.tsx +26 -0
  138. package/src/components/Tabs/components/TabsTrigger.tsx +69 -0
  139. package/src/components/Tabs/index.ts +3 -0
  140. package/src/components/TagChip/TagChip.tsx +46 -0
  141. package/src/components/TagChip/index.ts +1 -0
  142. package/src/components/TagInput/TagInput.tsx +60 -0
  143. package/src/components/TagInput/index.ts +1 -0
  144. package/src/components/Text/Text.tsx +35 -0
  145. package/src/components/Text/index.ts +1 -0
  146. package/src/components/TextInline/TextInline.tsx +35 -0
  147. package/src/components/TextInline/index.ts +1 -0
  148. package/src/components/Textarea/Textarea.tsx +20 -0
  149. package/src/components/Textarea/index.ts +1 -0
  150. package/src/components/TimePicker/PeriodSelector.tsx +28 -0
  151. package/src/components/TimePicker/TimeColumn.tsx +62 -0
  152. package/src/components/TimePicker/TimePicker.tsx +323 -0
  153. package/src/components/TimePicker/index.ts +7 -0
  154. package/src/components/TimePicker/timepicker.hooks.ts +173 -0
  155. package/src/components/TimePicker/timepicker.types.ts +101 -0
  156. package/src/components/Toast/Toast.tsx +111 -0
  157. package/src/components/Toast/index.ts +1 -0
  158. package/src/components/Toggle/Toggle.tsx +72 -0
  159. package/src/components/Toggle/index.ts +1 -0
  160. package/src/components/ToggleGroup/ToggleGroup.tsx +14 -0
  161. package/src/components/ToggleGroup/ToggleGroupContainer.tsx +19 -0
  162. package/src/components/ToggleGroup/ToggleGroupItem.tsx +51 -0
  163. package/src/components/ToggleGroup/ToggleGroupMultiple.tsx +61 -0
  164. package/src/components/ToggleGroup/ToggleGroupSingle.tsx +105 -0
  165. package/src/components/ToggleGroup/index.ts +2 -0
  166. package/src/components/ToggleGroup/toggleGroup.types.ts +55 -0
  167. package/src/components/Tooltip/Tooltip.tsx +66 -0
  168. package/src/components/Tooltip/components/TooltipBody.tsx +30 -0
  169. package/src/components/Tooltip/components/TooltipContext.tsx +22 -0
  170. package/src/components/Tooltip/components/TooltipTrigger.tsx +27 -0
  171. package/src/components/Tooltip/index.ts +9 -0
  172. package/src/date-time/arithmetic.ts +101 -0
  173. package/src/date-time/boundaries.ts +91 -0
  174. package/src/date-time/compare.ts +85 -0
  175. package/src/date-time/duration.ts +63 -0
  176. package/src/date-time/elapsed.ts +18 -0
  177. package/src/date-time/format.ts +722 -0
  178. package/src/date-time/index.ts +279 -0
  179. package/src/date-time/now.ts +70 -0
  180. package/src/date-time/parse.ts +170 -0
  181. package/src/date-time/serialize.ts +90 -0
  182. package/src/date-time/timezone.ts +47 -0
  183. package/src/date-time/validate.ts +278 -0
  184. package/src/hooks/index.ts +10 -0
  185. package/src/hooks/useCollapsible.ts +16 -0
  186. package/src/hooks/useControllableState.ts +54 -0
  187. package/src/hooks/useDebouncedCallback.ts +21 -0
  188. package/src/hooks/useDebouncedState.ts +20 -0
  189. package/src/hooks/useDeviceFormFactor.ts +49 -0
  190. package/src/hooks/useLocalStorage.ts +67 -0
  191. package/src/hooks/useMergedFloatingRef.ts +29 -0
  192. package/src/hooks/usePresence.ts +127 -0
  193. package/src/hooks/useStack.ts +99 -0
  194. package/src/hooks/useTheme.ts +105 -0
  195. package/src/internal/Slot.tsx +79 -0
  196. package/src/internal/index.ts +2 -0
  197. package/src/internal/rovingFocus.ts +39 -0
  198. package/src/internal/types.ts +10 -0
  199. package/src/lib/EmptyBoundary.tsx +12 -0
  200. package/src/lib/ErrorBoundary.tsx +48 -0
  201. package/src/lib/contexts/ComponentsContext/ComponentsContext.tsx +34 -0
  202. package/src/lib/contexts/ComponentsContext/index.ts +1 -0
  203. package/src/lib/contexts/OverlayContext/OverlayContext.tsx +93 -0
  204. package/src/lib/contexts/OverlayContext/hooks/useModal.tsx +125 -0
  205. package/src/lib/contexts/OverlayContext/hooks/useToast.ts +80 -0
  206. package/src/lib/contexts/OverlayContext/index.ts +6 -0
  207. package/src/lib/format.ts +51 -0
  208. package/src/lib/utils.ts +10 -0
  209. package/src/lib/withEmpty.tsx +14 -0
  210. package/src/lib/withErrorBoundary.tsx +16 -0
  211. package/src/lib/withSuspense.tsx +16 -0
  212. package/src/lib/withSuspenseAndErrorBoundary.tsx +26 -0
  213. package/src/styles/base.css +41 -0
  214. package/src/theme/index.ts +37 -0
  215. package/src/theme/palette.json +100 -0
  216. package/src/theme/palette.schema.json +40 -0
  217. package/src/theme/schema.ts +122 -0
  218. package/src/theme/theme.json +349 -0
  219. package/src/theme/theme.schema.json +876 -0
  220. package/src/theme/typography.json +7 -0
  221. package/src/theme/typography.schema.json +36 -0
  222. package/src/typography/Body.tsx +8 -0
  223. package/src/typography/Caption.tsx +8 -0
  224. package/src/typography/Code.tsx +8 -0
  225. package/src/typography/Display.tsx +8 -0
  226. package/src/typography/Emphasis.tsx +8 -0
  227. package/src/typography/Heading.tsx +23 -0
  228. package/src/typography/Label.tsx +10 -0
  229. package/src/typography/Lead.tsx +8 -0
  230. package/src/typography/Mono.tsx +8 -0
  231. package/src/typography/Muted.tsx +8 -0
  232. package/src/typography/SectionTitle.tsx +8 -0
  233. package/src/typography/Subtitle.tsx +8 -0
  234. package/src/typography/Timestamp.tsx +8 -0
  235. package/src/typography/index.ts +10 -0
  236. package/tailwind.config.ts +261 -0
@@ -0,0 +1,679 @@
1
+ import { autoUpdate, FloatingPortal, flip, offset, shift, useClick, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
2
+ import { useControllableState } from "@sixthshift/design-system/hooks";
3
+ import { cn } from "@sixthshift/design-system/utils";
4
+ import { Calendar as CalendarIcon, X } from "lucide-react";
5
+ import type * as React from "react";
6
+ import { useCallback, useId, useMemo, useState } from "react";
7
+ import { now, Temporal, today } from "../../date-time";
8
+
9
+ import { Button } from "../Button";
10
+ import { Calendar } from "../Calendar";
11
+ import type { DateRangeValue } from "../Calendar/calendar.types";
12
+ import { Separator } from "../Separator";
13
+ import { PeriodSelector } from "../TimePicker/PeriodSelector";
14
+ import { TimeColumn } from "../TimePicker/TimeColumn";
15
+ import {
16
+ generateHours,
17
+ generateMinutes,
18
+ generateSeconds,
19
+ getCurrentTime,
20
+ isTimeDisabled,
21
+ temporalToParsed,
22
+ to12Hour,
23
+ to24Hour,
24
+ } from "../TimePicker/timepicker.hooks";
25
+ import type { ParsedTime, TimePeriod } from "../TimePicker/timepicker.types";
26
+ import type { DateTimeRangePickerProps, DateTimeRangePresetOption, DateTimeRangeValue } from "./datetimerangepicker.types";
27
+
28
+ /**
29
+ * Format an Instant for display
30
+ */
31
+ function formatInstantDisplay(instant: Temporal.Instant, clockFormat: "12h" | "24h", showSeconds: boolean, timeZone?: string): string {
32
+ const tz = timeZone ?? Temporal.Now.timeZoneId();
33
+ const zoned = instant.toZonedDateTimeISO(tz);
34
+ const plainDateTime = zoned.toPlainDateTime();
35
+
36
+ // Format date part
37
+ const datePart = plainDateTime.toLocaleString("en-US", {
38
+ month: "long",
39
+ day: "numeric",
40
+ year: "numeric",
41
+ });
42
+
43
+ // Format time part based on props
44
+ const timeOptions: Intl.DateTimeFormatOptions = {
45
+ hour: "numeric",
46
+ minute: "2-digit",
47
+ hour12: clockFormat === "12h",
48
+ };
49
+
50
+ if (showSeconds) {
51
+ timeOptions.second = "2-digit";
52
+ }
53
+
54
+ const timePart = plainDateTime.toLocaleString("en-US", timeOptions);
55
+
56
+ return `${datePart}, ${timePart}`;
57
+ }
58
+
59
+ /**
60
+ * Format an Instant range for display
61
+ */
62
+ function formatRangeDisplay(range: DateTimeRangeValue | undefined, clockFormat: "12h" | "24h", showSeconds: boolean): string {
63
+ if (!range?.from && !range?.to) {
64
+ return "";
65
+ }
66
+
67
+ if (range.from && !range.to) {
68
+ return `${formatInstantDisplay(range.from, clockFormat, showSeconds)} – ...`;
69
+ }
70
+
71
+ if (!range.from && range.to) {
72
+ return `... – ${formatInstantDisplay(range.to, clockFormat, showSeconds)}`;
73
+ }
74
+
75
+ return `${formatInstantDisplay(range.from!, clockFormat, showSeconds)} – ${formatInstantDisplay(range.to!, clockFormat, showSeconds)}`;
76
+ }
77
+
78
+ /**
79
+ * Default preset ranges for DateTimeRangePicker
80
+ *
81
+ * Uses clean hour boundaries and returns Instant values
82
+ */
83
+ function getDefaultPresets(): DateTimeRangePresetOption[] {
84
+ return [
85
+ {
86
+ label: "Last hour",
87
+ value: () => {
88
+ const end = now().round({ smallestUnit: "hour", roundingMode: "ceil" });
89
+ const start = end.subtract({ hours: 1 });
90
+ return { from: start, to: end };
91
+ },
92
+ },
93
+ {
94
+ label: "Last 24 hours",
95
+ value: () => {
96
+ const end = now().round({ smallestUnit: "hour", roundingMode: "ceil" });
97
+ const start = end.subtract({ hours: 24 });
98
+ return { from: start, to: end };
99
+ },
100
+ },
101
+ {
102
+ label: "Last 7 days",
103
+ value: () => {
104
+ const end = now().round({ smallestUnit: "hour", roundingMode: "ceil" });
105
+ const start = end.subtract({ hours: 7 * 24 });
106
+ return { from: start, to: end };
107
+ },
108
+ },
109
+ {
110
+ label: "Last 30 days",
111
+ value: () => {
112
+ const end = now().round({ smallestUnit: "hour", roundingMode: "ceil" });
113
+ const start = end.subtract({ hours: 30 * 24 });
114
+ return { from: start, to: end };
115
+ },
116
+ },
117
+ {
118
+ label: "This month",
119
+ value: () => {
120
+ const end = now().round({ smallestUnit: "hour", roundingMode: "ceil" });
121
+ const tz = Temporal.Now.timeZoneId();
122
+ const zonedNow = end.toZonedDateTimeISO(tz);
123
+ const startOfMonth = zonedNow.toPlainDate().with({ day: 1 }).toPlainDateTime(Temporal.PlainTime.from("00:00:00")).toZonedDateTime(tz).toInstant();
124
+ return { from: startOfMonth, to: end };
125
+ },
126
+ },
127
+ ];
128
+ }
129
+
130
+ /**
131
+ * DateTimeRangePicker - A component for selecting a datetime range
132
+ *
133
+ * Combines date range selection with separate time pickers for start and end times.
134
+ * Uses Temporal.PlainDateTime for all values.
135
+ */
136
+ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => {
137
+ const {
138
+ value,
139
+ defaultValue,
140
+ onChange,
141
+ presets: customPresets,
142
+ showPresets = true,
143
+ minDate,
144
+ maxDate,
145
+ disabledDates,
146
+ minTime,
147
+ maxTime,
148
+ minuteStep = 1,
149
+ clockFormat = "12h",
150
+ showSeconds = false,
151
+ placeholder = "Select datetime range...",
152
+ weekStartsOn = 0,
153
+ name,
154
+ isDisabled = false,
155
+ isInvalid = false,
156
+ className,
157
+ align = "end",
158
+ clearable = true,
159
+ } = props;
160
+
161
+ // Open state
162
+ const [open, setOpen] = useState(false);
163
+
164
+ // Controllable value state (external/committed value)
165
+ const [committedValue, setCommittedValue] = useControllableState({
166
+ value,
167
+ defaultValue,
168
+ onChange,
169
+ });
170
+
171
+ // Draft state (date range + time parts for both start and end)
172
+ // Convert Instant to ZonedDateTime for editing
173
+ const userTimeZone = Temporal.Now.timeZoneId();
174
+ const zonedFrom = committedValue?.from?.toZonedDateTimeISO(userTimeZone);
175
+ const zonedTo = committedValue?.to?.toZonedDateTimeISO(userTimeZone);
176
+
177
+ const [draftDateRange, setDraftDateRange] = useState<DateRangeValue | undefined>(() => ({
178
+ from: zonedFrom?.toPlainDate(),
179
+ to: zonedTo?.toPlainDate(),
180
+ }));
181
+
182
+ // Start time state
183
+ const [draftStartHour, setDraftStartHour] = useState<number>(12);
184
+ const [draftStartMinute, setDraftStartMinute] = useState<number>(0);
185
+ const [draftStartSecond, setDraftStartSecond] = useState<number>(0);
186
+ const [draftStartPeriod, setDraftStartPeriod] = useState<TimePeriod>("AM");
187
+
188
+ // End time state
189
+ const [draftEndHour, setDraftEndHour] = useState<number>(12);
190
+ const [draftEndMinute, setDraftEndMinute] = useState<number>(0);
191
+ const [draftEndSecond, setDraftEndSecond] = useState<number>(0);
192
+ const [draftEndPeriod, setDraftEndPeriod] = useState<TimePeriod>("PM");
193
+
194
+ // Month navigation state (for calendar)
195
+ const [month, setMonth] = useState<Temporal.PlainDate>(() => {
196
+ if (committedValue?.from) {
197
+ const tz = Temporal.Now.timeZoneId();
198
+ const zoned = committedValue.from.toZonedDateTimeISO(tz);
199
+ return zoned.toPlainDate();
200
+ }
201
+ return today();
202
+ });
203
+
204
+ // Compute presets
205
+ const presetOptions = useMemo(() => {
206
+ if (!showPresets) return undefined;
207
+ const presetsToUse = customPresets || getDefaultPresets();
208
+ return presetsToUse.map((preset) => ({
209
+ label: preset.label,
210
+ value: preset.value(),
211
+ }));
212
+ }, [customPresets, showPresets]);
213
+
214
+ // Sync draft when popup opens
215
+ const handleOpenChange = useCallback(
216
+ (newOpen: boolean) => {
217
+ if (newOpen) {
218
+ const tz = Temporal.Now.timeZoneId();
219
+ if (committedValue?.from && committedValue?.to) {
220
+ // Convert Instant to ZonedDateTime for editing
221
+ const zonedFrom = committedValue.from.toZonedDateTimeISO(tz);
222
+ const zonedTo = committedValue.to.toZonedDateTimeISO(tz);
223
+ const dateTimeFrom = zonedFrom.toPlainDateTime();
224
+ const dateTimeTo = zonedTo.toPlainDateTime();
225
+
226
+ // Set draft from committed value
227
+ setDraftDateRange({
228
+ from: dateTimeFrom.toPlainDate(),
229
+ to: dateTimeTo.toPlainDate(),
230
+ });
231
+
232
+ const startParsed = temporalToParsed(dateTimeFrom.toPlainTime());
233
+ setDraftStartHour(clockFormat === "12h" ? to12Hour(startParsed.hour).hour12 : startParsed.hour);
234
+ setDraftStartMinute(startParsed.minute);
235
+ setDraftStartSecond(startParsed.second);
236
+ setDraftStartPeriod(to12Hour(startParsed.hour).period);
237
+
238
+ const endParsed = temporalToParsed(dateTimeTo.toPlainTime());
239
+ setDraftEndHour(clockFormat === "12h" ? to12Hour(endParsed.hour).hour12 : endParsed.hour);
240
+ setDraftEndMinute(endParsed.minute);
241
+ setDraftEndSecond(endParsed.second);
242
+ setDraftEndPeriod(to12Hour(endParsed.hour).period);
243
+
244
+ setMonth(dateTimeFrom.toPlainDate());
245
+ } else if (committedValue?.from) {
246
+ // Only start is set
247
+ const zonedFrom = committedValue.from.toZonedDateTimeISO(tz);
248
+ const dateTimeFrom = zonedFrom.toPlainDateTime();
249
+
250
+ setDraftDateRange({
251
+ from: dateTimeFrom.toPlainDate(),
252
+ to: undefined,
253
+ });
254
+
255
+ const startParsed = temporalToParsed(dateTimeFrom.toPlainTime());
256
+ setDraftStartHour(clockFormat === "12h" ? to12Hour(startParsed.hour).hour12 : startParsed.hour);
257
+ setDraftStartMinute(startParsed.minute);
258
+ setDraftStartSecond(startParsed.second);
259
+ setDraftStartPeriod(to12Hour(startParsed.hour).period);
260
+
261
+ setMonth(dateTimeFrom.toPlainDate());
262
+ } else {
263
+ // No committed value - start fresh with empty range
264
+ const now = getCurrentTime();
265
+
266
+ setDraftDateRange({ from: undefined, to: undefined });
267
+
268
+ const currentHour = now.hour;
269
+ setDraftStartHour(clockFormat === "12h" ? to12Hour(currentHour).hour12 : currentHour);
270
+ setDraftStartMinute(now.minute);
271
+ setDraftStartSecond(now.second);
272
+ setDraftStartPeriod(to12Hour(currentHour).period);
273
+
274
+ setDraftEndHour(12);
275
+ setDraftEndMinute(0);
276
+ setDraftEndSecond(0);
277
+ setDraftEndPeriod("PM");
278
+
279
+ setMonth(today());
280
+ }
281
+ }
282
+ setOpen(newOpen);
283
+ },
284
+ [committedValue, clockFormat]
285
+ );
286
+
287
+ // Get the current draft times as parsed times (24-hour)
288
+ const getDraftStartTime = useCallback((): ParsedTime => {
289
+ const hour24 = clockFormat === "12h" ? to24Hour(draftStartHour, draftStartPeriod) : draftStartHour;
290
+ return {
291
+ hour: hour24,
292
+ minute: draftStartMinute,
293
+ second: draftStartSecond,
294
+ };
295
+ }, [clockFormat, draftStartHour, draftStartMinute, draftStartSecond, draftStartPeriod]);
296
+
297
+ const getDraftEndTime = useCallback((): ParsedTime => {
298
+ const hour24 = clockFormat === "12h" ? to24Hour(draftEndHour, draftEndPeriod) : draftEndHour;
299
+ return {
300
+ hour: hour24,
301
+ minute: draftEndMinute,
302
+ second: draftEndSecond,
303
+ };
304
+ }, [clockFormat, draftEndHour, draftEndMinute, draftEndSecond, draftEndPeriod]);
305
+
306
+ // Combine draft dates and times into DateTimeRangeValue
307
+ const getDraftRange = useCallback((): DateTimeRangeValue => {
308
+ const tz = Temporal.Now.timeZoneId();
309
+
310
+ const from = draftDateRange?.from
311
+ ? draftDateRange.from
312
+ .toPlainDateTime({
313
+ hour: getDraftStartTime().hour,
314
+ minute: getDraftStartTime().minute,
315
+ second: getDraftStartTime().second,
316
+ })
317
+ .toZonedDateTime(tz)
318
+ .toInstant()
319
+ : undefined;
320
+
321
+ const to = draftDateRange?.to
322
+ ? draftDateRange.to
323
+ .toPlainDateTime({
324
+ hour: getDraftEndTime().hour,
325
+ minute: getDraftEndTime().minute,
326
+ second: getDraftEndTime().second,
327
+ })
328
+ .toZonedDateTime(tz)
329
+ .toInstant()
330
+ : undefined;
331
+
332
+ return { from, to };
333
+ }, [draftDateRange, getDraftStartTime, getDraftEndTime]);
334
+
335
+ // Apply changes
336
+ const handleApply = useCallback(() => {
337
+ const range = getDraftRange();
338
+
339
+ // Validate that end is after start
340
+ if (range.from && range.to && Temporal.Instant.compare(range.from, range.to) > 0) {
341
+ // Swap them
342
+ setCommittedValue({ from: range.to, to: range.from });
343
+ } else {
344
+ setCommittedValue(range.from || range.to ? range : undefined);
345
+ }
346
+
347
+ setOpen(false);
348
+ }, [getDraftRange, setCommittedValue]);
349
+
350
+ // Cancel changes
351
+ const handleCancel = useCallback(() => {
352
+ const tz = Temporal.Now.timeZoneId();
353
+ // Revert to committed value
354
+ if (committedValue?.from) {
355
+ const zonedFrom = committedValue.from.toZonedDateTimeISO(tz);
356
+ const dateTimeFrom = zonedFrom.toPlainDateTime();
357
+
358
+ setDraftDateRange({
359
+ from: dateTimeFrom.toPlainDate(),
360
+ to: committedValue.to ? committedValue.to.toZonedDateTimeISO(tz).toPlainDateTime().toPlainDate() : undefined,
361
+ });
362
+
363
+ const startParsed = temporalToParsed(dateTimeFrom.toPlainTime());
364
+ setDraftStartHour(clockFormat === "12h" ? to12Hour(startParsed.hour).hour12 : startParsed.hour);
365
+ setDraftStartMinute(startParsed.minute);
366
+ setDraftStartSecond(startParsed.second);
367
+ setDraftStartPeriod(to12Hour(startParsed.hour).period);
368
+ }
369
+ if (committedValue?.to) {
370
+ const zonedTo = committedValue.to.toZonedDateTimeISO(tz);
371
+ const dateTimeTo = zonedTo.toPlainDateTime();
372
+
373
+ const endParsed = temporalToParsed(dateTimeTo.toPlainTime());
374
+ setDraftEndHour(clockFormat === "12h" ? to12Hour(endParsed.hour).hour12 : endParsed.hour);
375
+ setDraftEndMinute(endParsed.minute);
376
+ setDraftEndSecond(endParsed.second);
377
+ setDraftEndPeriod(to12Hour(endParsed.hour).period);
378
+ }
379
+ setOpen(false);
380
+ }, [committedValue, clockFormat]);
381
+
382
+ // Clear value
383
+ const handleClear = useCallback(
384
+ (e: React.MouseEvent) => {
385
+ e.stopPropagation();
386
+ e.preventDefault();
387
+ setCommittedValue(undefined);
388
+ },
389
+ [setCommittedValue]
390
+ );
391
+
392
+ // Apply preset
393
+ const _handlePresetClick = useCallback(
394
+ (preset: { label: string; value: DateTimeRangeValue }) => {
395
+ setCommittedValue(preset.value);
396
+ setOpen(false);
397
+ },
398
+ [setCommittedValue]
399
+ );
400
+
401
+ // Floating UI setup
402
+ const { refs, floatingStyles, context } = useFloating({
403
+ open,
404
+ onOpenChange: handleOpenChange,
405
+ placement: align === "start" ? "bottom-start" : "bottom-end",
406
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
407
+ whileElementsMounted: autoUpdate,
408
+ });
409
+
410
+ const click = useClick(context);
411
+ const dismiss = useDismiss(context);
412
+ const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
413
+
414
+ // IDs for accessibility
415
+ const id = useId();
416
+ const contentId = `datetimerangepicker-content-${id}`;
417
+ const inputId = `datetimerangepicker-input-${id}`;
418
+
419
+ // Display value (from committed value)
420
+ const displayValue = committedValue ? formatRangeDisplay(committedValue, clockFormat, showSeconds) : "";
421
+ const hasValue = Boolean(committedValue?.from || committedValue?.to);
422
+
423
+ // Time selection handlers - Start time
424
+ const handleStartHourChange = (hour: number) => setDraftStartHour(hour);
425
+ const handleStartMinuteChange = (minute: number) => setDraftStartMinute(minute);
426
+ const handleStartSecondChange = (second: number) => setDraftStartSecond(second);
427
+ const handleStartPeriodChange = (period: TimePeriod) => setDraftStartPeriod(period);
428
+
429
+ // Time selection handlers - End time
430
+ const handleEndHourChange = (hour: number) => setDraftEndHour(hour);
431
+ const handleEndMinuteChange = (minute: number) => setDraftEndMinute(minute);
432
+ const handleEndSecondChange = (second: number) => setDraftEndSecond(second);
433
+ const handleEndPeriodChange = (period: TimePeriod) => setDraftEndPeriod(period);
434
+
435
+ // Generate time options
436
+ const hours = generateHours(clockFormat);
437
+ const minutes = generateMinutes(minuteStep);
438
+ const seconds = generateSeconds();
439
+
440
+ // Get current draft times
441
+ const draftStartTime = getDraftStartTime();
442
+ const draftEndTime = getDraftEndTime();
443
+
444
+ // Check if a time is disabled
445
+ const checkTimeDisabled = useCallback(
446
+ (hour: number, minute: number, second: number): boolean => {
447
+ const temporal = Temporal.PlainTime.from({ hour, minute, second });
448
+ return isTimeDisabled(temporal, minTime, maxTime);
449
+ },
450
+ [minTime, maxTime]
451
+ );
452
+
453
+ return (
454
+ <>
455
+ {/* Trigger Input */}
456
+ <div className="relative flex min-w-125 items-center">
457
+ <CalendarIcon className="pointer-events-none absolute left-3 h-4 w-4 text-fg-subtle" />
458
+ <input
459
+ ref={refs.setReference as React.Ref<HTMLInputElement>}
460
+ id={inputId}
461
+ type="text"
462
+ readOnly
463
+ value={displayValue}
464
+ placeholder={placeholder}
465
+ disabled={isDisabled}
466
+ role="combobox"
467
+ aria-invalid={isInvalid}
468
+ aria-haspopup="dialog"
469
+ aria-expanded={open}
470
+ aria-controls={open ? contentId : undefined}
471
+ className={cn(
472
+ `flex h-9 w-full min-w-125 cursor-pointer rounded-md border border-border-normal bg-transparent py-1 pl-9 text-sm shadow-xs transition-colors placeholder:text-fg-subtle focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50`,
473
+ clearable && hasValue ? "pr-9" : "pr-3",
474
+ isInvalid && "border-border-danger",
475
+ className
476
+ )}
477
+ {...getReferenceProps()}
478
+ />
479
+ {clearable && hasValue && !isDisabled && (
480
+ <button
481
+ type="button"
482
+ onClick={handleClear}
483
+ className="absolute right-3 rounded-sm p-0.5 text-fg-subtle hover:bg-bg-subtle hover:text-fg-normal focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring"
484
+ aria-label="Clear datetime range"
485
+ >
486
+ <X className="h-3.5 w-3.5" />
487
+ </button>
488
+ )}
489
+
490
+ {/* Hidden inputs for form submission */}
491
+ {name && committedValue?.from && <input type="hidden" name={`${name}.from`} value={committedValue.from.toString()} />}
492
+ {name && committedValue?.to && <input type="hidden" name={`${name}.to`} value={committedValue.to.toString()} />}
493
+ </div>
494
+
495
+ {/* Popover Content */}
496
+ {open && (
497
+ <FloatingPortal>
498
+ <div
499
+ ref={refs.setFloating}
500
+ id={contentId}
501
+ role="dialog"
502
+ aria-modal="true"
503
+ style={floatingStyles}
504
+ className="z-popover rounded-xl border border-border-normal bg-bg-normal p-4 shadow-lg"
505
+ {...getFloatingProps()}
506
+ >
507
+ {/* Top row: Presets + Calendar */}
508
+ <div className="flex gap-4">
509
+ {presetOptions && presetOptions.length > 0 && (
510
+ <>
511
+ <div className="flex flex-col gap-1">
512
+ <div className="mb-2 font-semibold text-sm">Presets</div>
513
+ <div className="flex flex-col gap-1">
514
+ {presetOptions.map((preset) => (
515
+ <button
516
+ key={preset.label}
517
+ type="button"
518
+ onClick={() => _handlePresetClick(preset)}
519
+ className={cn(
520
+ "rounded-md px-3 py-1.5 text-left text-sm transition-colors",
521
+ "hover:bg-bg-subtle focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
522
+ )}
523
+ >
524
+ {preset.label}
525
+ </button>
526
+ ))}
527
+ </div>
528
+ </div>
529
+ <Separator orientation="vertical" />
530
+ </>
531
+ )}
532
+
533
+ {/* Date Calendar */}
534
+ <Calendar
535
+ mode="range"
536
+ value={draftDateRange}
537
+ onSelect={setDraftDateRange as (range: DateRangeValue | undefined) => void}
538
+ month={month}
539
+ onMonthChange={setMonth}
540
+ minDate={minDate}
541
+ maxDate={maxDate}
542
+ disabled={disabledDates}
543
+ weekStartsOn={weekStartsOn}
544
+ showFooter={false}
545
+ />
546
+ </div>
547
+
548
+ <Separator className="my-3" />
549
+
550
+ {/* Bottom row: Time Pickers */}
551
+ <div className="flex gap-4">
552
+ {/* Start Time Picker */}
553
+ <div className="flex flex-1 flex-col gap-3">
554
+ <div className="font-semibold text-sm">Start Time</div>
555
+
556
+ {/* Time columns */}
557
+ <div className="flex gap-2">
558
+ {/* Hours */}
559
+ <div className="flex flex-col gap-1">
560
+ <div className="text-center font-medium text-fg-subtle text-xs">Hour</div>
561
+ <TimeColumn
562
+ values={hours}
563
+ selected={draftStartHour}
564
+ onSelect={handleStartHourChange}
565
+ disabled={(hour: number) => {
566
+ const hour24 = clockFormat === "12h" ? to24Hour(hour, draftStartPeriod) : hour;
567
+ return checkTimeDisabled(hour24, draftStartMinute, draftStartSecond);
568
+ }}
569
+ />
570
+ </div>
571
+
572
+ {/* Minutes */}
573
+ <div className="flex flex-col gap-1">
574
+ <div className="text-center font-medium text-fg-subtle text-xs">Min</div>
575
+ <TimeColumn
576
+ values={minutes}
577
+ selected={draftStartMinute}
578
+ onSelect={handleStartMinuteChange}
579
+ disabled={(minute: number) => checkTimeDisabled(draftStartTime.hour, minute, draftStartSecond)}
580
+ />
581
+ </div>
582
+
583
+ {/* Seconds (optional) */}
584
+ {showSeconds && (
585
+ <div className="flex flex-col gap-1">
586
+ <div className="text-center font-medium text-fg-subtle text-xs">Sec</div>
587
+ <TimeColumn
588
+ values={seconds}
589
+ selected={draftStartSecond}
590
+ onSelect={handleStartSecondChange}
591
+ disabled={(second: number) => checkTimeDisabled(draftStartTime.hour, draftStartMinute, second)}
592
+ />
593
+ </div>
594
+ )}
595
+
596
+ {/* AM/PM (12h format only) */}
597
+ {clockFormat === "12h" && (
598
+ <div className="flex flex-col gap-1">
599
+ <div className="text-center font-medium text-fg-subtle text-xs">&nbsp;</div>
600
+ <PeriodSelector value={draftStartPeriod} onChange={handleStartPeriodChange} />
601
+ </div>
602
+ )}
603
+ </div>
604
+ </div>
605
+
606
+ <Separator orientation="vertical" />
607
+
608
+ {/* End Time Picker */}
609
+ <div className="flex flex-1 flex-col gap-3">
610
+ <div className="font-semibold text-sm">End Time</div>
611
+
612
+ {/* Time columns */}
613
+ <div className="flex gap-2">
614
+ {/* Hours */}
615
+ <div className="flex flex-col gap-1">
616
+ <div className="text-center font-medium text-fg-subtle text-xs">Hour</div>
617
+ <TimeColumn
618
+ values={hours}
619
+ selected={draftEndHour}
620
+ onSelect={handleEndHourChange}
621
+ disabled={(hour: number) => {
622
+ const hour24 = clockFormat === "12h" ? to24Hour(hour, draftEndPeriod) : hour;
623
+ return checkTimeDisabled(hour24, draftEndMinute, draftEndSecond);
624
+ }}
625
+ />
626
+ </div>
627
+
628
+ {/* Minutes */}
629
+ <div className="flex flex-col gap-1">
630
+ <div className="text-center font-medium text-fg-subtle text-xs">Min</div>
631
+ <TimeColumn
632
+ values={minutes}
633
+ selected={draftEndMinute}
634
+ onSelect={handleEndMinuteChange}
635
+ disabled={(minute: number) => checkTimeDisabled(draftEndTime.hour, minute, draftEndSecond)}
636
+ />
637
+ </div>
638
+
639
+ {/* Seconds (optional) */}
640
+ {showSeconds && (
641
+ <div className="flex flex-col gap-1">
642
+ <div className="text-center font-medium text-fg-subtle text-xs">Sec</div>
643
+ <TimeColumn
644
+ values={seconds}
645
+ selected={draftEndSecond}
646
+ onSelect={handleEndSecondChange}
647
+ disabled={(second: number) => checkTimeDisabled(draftEndTime.hour, draftEndMinute, second)}
648
+ />
649
+ </div>
650
+ )}
651
+
652
+ {/* AM/PM (12h format only) */}
653
+ {clockFormat === "12h" && (
654
+ <div className="flex flex-col gap-1">
655
+ <div className="text-center font-medium text-fg-subtle text-xs">&nbsp;</div>
656
+ <PeriodSelector value={draftEndPeriod} onChange={handleEndPeriodChange} />
657
+ </div>
658
+ )}
659
+ </div>
660
+ </div>
661
+ </div>
662
+
663
+ <Separator className="my-3" />
664
+
665
+ {/* Footer with Cancel / Apply */}
666
+ <div className="flex items-center justify-end gap-2">
667
+ <Button type="button" variant="ghost" size="sm" onClick={handleCancel}>
668
+ Cancel
669
+ </Button>
670
+ <Button type="button" variant="solid" size="sm" onClick={handleApply}>
671
+ Apply
672
+ </Button>
673
+ </div>
674
+ </div>
675
+ </FloatingPortal>
676
+ )}
677
+ </>
678
+ );
679
+ };