@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,28 @@
1
+ import { cn } from "@sixthshift/design-system/utils";
2
+ import type { TimePeriod } from "./timepicker.types";
3
+
4
+ export type PeriodSelectorProps = {
5
+ value: TimePeriod;
6
+ onChange: (period: TimePeriod) => void;
7
+ };
8
+
9
+ export const PeriodSelector = ({ value, onChange }: PeriodSelectorProps) => {
10
+ return (
11
+ <div className="flex h-48 w-12 flex-col items-center justify-center gap-1 rounded-lg border border-border-subtle bg-bg-subtle/50">
12
+ {(["AM", "PM"] as const).map((period) => (
13
+ <button
14
+ key={period}
15
+ type="button"
16
+ onClick={() => onChange(period)}
17
+ className={cn(
18
+ `w-10 cursor-pointer rounded-md py-1.5 text-center text-sm transition-colors focus-visible:outline-hidden`,
19
+ `hover:bg-bg-subtle focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset`,
20
+ value === period && "bg-bg-brand text-fg-on-brand hover:bg-bg-brand-strong"
21
+ )}
22
+ >
23
+ {period}
24
+ </button>
25
+ ))}
26
+ </div>
27
+ );
28
+ };
@@ -0,0 +1,62 @@
1
+ import { cn } from "@sixthshift/design-system/utils";
2
+ import { useEffect, useRef } from "react";
3
+
4
+ export type TimeColumnProps = {
5
+ values: number[];
6
+ selected: number;
7
+ onSelect: (value: number) => void;
8
+ formatValue?: (value: number) => string;
9
+ disabled?: (value: number) => boolean;
10
+ };
11
+
12
+ export const TimeColumn = ({ values, selected, onSelect, formatValue = (v) => String(v).padStart(2, "0"), disabled }: TimeColumnProps) => {
13
+ const containerRef = useRef<HTMLDivElement>(null);
14
+ const selectedRef = useRef<HTMLButtonElement>(null);
15
+
16
+ // Scroll selected item into view on mount and when selection changes
17
+ useEffect(() => {
18
+ if (selectedRef.current && containerRef.current) {
19
+ const container = containerRef.current;
20
+ const item = selectedRef.current;
21
+ const containerRect = container.getBoundingClientRect();
22
+ const itemRect = item.getBoundingClientRect();
23
+ const scrollTop = item.offsetTop - container.offsetTop - containerRect.height / 2 + itemRect.height / 2;
24
+ container.scrollTo({ top: scrollTop, behavior: "smooth" });
25
+ }
26
+ }, []);
27
+
28
+ return (
29
+ <div ref={containerRef} role="listbox" className="h-48 w-14 overflow-y-auto overscroll-contain rounded-lg border border-border-subtle bg-bg-subtle/50">
30
+ {/* Top spacer for centering first items */}
31
+ <div className="h-19" aria-hidden="true" />
32
+ <div className="flex flex-col gap-0.5">
33
+ {values.map((value) => {
34
+ const isSelected = value === selected;
35
+ const isDisabled = disabled?.(value) ?? false;
36
+
37
+ return (
38
+ <button
39
+ key={value}
40
+ ref={isSelected ? selectedRef : undefined}
41
+ type="button"
42
+ role="option"
43
+ aria-selected={isSelected}
44
+ onClick={() => !isDisabled && onSelect(value)}
45
+ disabled={isDisabled}
46
+ className={cn(
47
+ `mx-1 cursor-pointer rounded-md py-1.5 text-center text-sm transition-colors focus-visible:outline-hidden`,
48
+ `hover:bg-bg-subtle focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset`,
49
+ isSelected && "bg-bg-brand text-fg-on-brand hover:bg-bg-brand-strong",
50
+ isDisabled && "cursor-not-allowed opacity-50"
51
+ )}
52
+ >
53
+ {formatValue(value)}
54
+ </button>
55
+ );
56
+ })}
57
+ </div>
58
+ {/* Bottom spacer for centering last items */}
59
+ <div className="h-19" aria-hidden="true" />
60
+ </div>
61
+ );
62
+ };
@@ -0,0 +1,323 @@
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 { Clock, X } from "lucide-react";
5
+ import type * as React from "react";
6
+ import { useCallback, useId, useState } from "react";
7
+ import { Button } from "../Button";
8
+ import { Separator } from "../Separator";
9
+ import { PeriodSelector } from "./PeriodSelector";
10
+ import { TimeColumn } from "./TimeColumn";
11
+ import {
12
+ formatTimeDisplay,
13
+ generateHours,
14
+ generateMinutes,
15
+ generateSeconds,
16
+ getCurrentTime,
17
+ isTimeDisabled,
18
+ parsedToTemporal,
19
+ temporalTimeToISO,
20
+ temporalToParsed,
21
+ to12Hour,
22
+ to24Hour,
23
+ } from "./timepicker.hooks";
24
+ import type { ParsedTime, TimePeriod, TimePickerProps, TimePresetOption } from "./timepicker.types";
25
+
26
+ export type { TimePickerProps };
27
+
28
+ export const TimePicker = (props: TimePickerProps) => {
29
+ const {
30
+ value,
31
+ defaultValue,
32
+ onChange,
33
+ format = "HH:mm",
34
+ clockFormat = "12h",
35
+ minuteStep = 1,
36
+ minTime,
37
+ maxTime,
38
+ presets,
39
+ placeholder = "Select time",
40
+ name,
41
+ isDisabled = false,
42
+ isInvalid = false,
43
+ className,
44
+ } = props;
45
+
46
+ // Open state
47
+ const [open, setOpen] = useState(false);
48
+
49
+ // Controllable value state (external/committed value)
50
+ const [committedValue, setCommittedValue] = useControllableState({
51
+ value,
52
+ defaultValue,
53
+ onChange,
54
+ });
55
+
56
+ // Parse committed value
57
+ const parsedCommitted = committedValue ? temporalToParsed(committedValue) : undefined;
58
+
59
+ // Draft state (internal while popup is open)
60
+ const [draftHour, setDraftHour] = useState<number>(parsedCommitted?.hour ?? 12);
61
+ const [draftMinute, setDraftMinute] = useState<number>(parsedCommitted?.minute ?? 0);
62
+ const [draftSecond, setDraftSecond] = useState<number>(parsedCommitted?.second ?? 0);
63
+ const [draftPeriod, setDraftPeriod] = useState<TimePeriod>(() => {
64
+ if (parsedCommitted) {
65
+ return to12Hour(parsedCommitted.hour).period;
66
+ }
67
+ return "AM";
68
+ });
69
+
70
+ // Sync draft when popup opens
71
+ const handleOpenChange = useCallback(
72
+ (newOpen: boolean) => {
73
+ if (newOpen) {
74
+ if (committedValue) {
75
+ const parsed = temporalToParsed(committedValue);
76
+ setDraftHour(clockFormat === "12h" ? to12Hour(parsed.hour).hour12 : parsed.hour);
77
+ setDraftMinute(parsed.minute);
78
+ setDraftSecond(parsed.second);
79
+ setDraftPeriod(to12Hour(parsed.hour).period);
80
+ } else {
81
+ // Default to current time
82
+ const now = getCurrentTime();
83
+ const currentHour = now.hour;
84
+ setDraftHour(clockFormat === "12h" ? to12Hour(currentHour).hour12 : currentHour);
85
+ setDraftMinute(now.minute);
86
+ setDraftSecond(now.second);
87
+ setDraftPeriod(to12Hour(currentHour).period);
88
+ }
89
+ }
90
+ setOpen(newOpen);
91
+ },
92
+ [committedValue, clockFormat]
93
+ );
94
+
95
+ // Get the current draft as a parsed time (24-hour)
96
+ const getDraftTime = useCallback((): ParsedTime => {
97
+ const hour24 = clockFormat === "12h" ? to24Hour(draftHour, draftPeriod) : draftHour;
98
+ return {
99
+ hour: hour24,
100
+ minute: draftMinute,
101
+ second: draftSecond,
102
+ };
103
+ }, [clockFormat, draftHour, draftMinute, draftSecond, draftPeriod]);
104
+
105
+ // Apply changes
106
+ const handleApply = useCallback(() => {
107
+ const parsed = getDraftTime();
108
+ const temporalTime = parsedToTemporal(parsed);
109
+
110
+ // Check if disabled
111
+ if (isTimeDisabled(temporalTime, minTime, maxTime)) {
112
+ return;
113
+ }
114
+
115
+ setCommittedValue(temporalTime);
116
+ setOpen(false);
117
+ }, [getDraftTime, minTime, maxTime, setCommittedValue]);
118
+
119
+ // Cancel changes
120
+ const handleCancel = useCallback(() => {
121
+ setOpen(false);
122
+ }, []);
123
+
124
+ // Clear value
125
+ const handleClear = useCallback(
126
+ (e: React.MouseEvent) => {
127
+ e.stopPropagation();
128
+ e.preventDefault();
129
+ setCommittedValue(undefined);
130
+ },
131
+ [setCommittedValue]
132
+ );
133
+
134
+ // Now button
135
+ const handleNow = useCallback(() => {
136
+ const now = getCurrentTime();
137
+ const currentHour = now.hour;
138
+ setDraftHour(clockFormat === "12h" ? to12Hour(currentHour).hour12 : currentHour);
139
+ setDraftMinute(now.minute);
140
+ setDraftSecond(now.second);
141
+ setDraftPeriod(to12Hour(currentHour).period);
142
+ }, [clockFormat]);
143
+
144
+ // Preset selection
145
+ const handlePresetClick = useCallback(
146
+ (preset: TimePresetOption) => {
147
+ const parsed = temporalToParsed(preset.value);
148
+ setDraftHour(clockFormat === "12h" ? to12Hour(parsed.hour).hour12 : parsed.hour);
149
+ setDraftMinute(parsed.minute);
150
+ setDraftSecond(parsed.second);
151
+ setDraftPeriod(to12Hour(parsed.hour).period);
152
+ },
153
+ [clockFormat]
154
+ );
155
+
156
+ // Check if a preset is currently active
157
+ const isPresetActive = useCallback(
158
+ (preset: TimePresetOption): boolean => {
159
+ const draftTime = getDraftTime();
160
+ const presetParsed = temporalToParsed(preset.value);
161
+
162
+ return (
163
+ draftTime.hour === presetParsed.hour && draftTime.minute === presetParsed.minute && (format === "HH:mm" || draftTime.second === presetParsed.second)
164
+ );
165
+ },
166
+ [getDraftTime, format]
167
+ );
168
+
169
+ // Floating UI setup
170
+ const { refs, floatingStyles, context } = useFloating({
171
+ open,
172
+ onOpenChange: handleOpenChange,
173
+ placement: "bottom-end",
174
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
175
+ whileElementsMounted: autoUpdate,
176
+ });
177
+
178
+ const click = useClick(context);
179
+ const dismiss = useDismiss(context);
180
+ const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
181
+
182
+ // IDs for accessibility
183
+ const id = useId();
184
+ const contentId = `timepicker-content-${id}`;
185
+ const inputId = `timepicker-input-${id}`;
186
+
187
+ // Display value
188
+ const displayValue = committedValue ? formatTimeDisplay(committedValue, clockFormat, format) : "";
189
+ const hasValue = Boolean(committedValue);
190
+
191
+ // Generate column values
192
+ const hours = generateHours(clockFormat);
193
+ const minutes = generateMinutes(minuteStep);
194
+ const seconds = generateSeconds();
195
+
196
+ return (
197
+ <>
198
+ {/* Trigger Input */}
199
+ <div className="relative flex items-center">
200
+ <Clock className="pointer-events-none absolute left-3 h-4 w-4 text-fg-subtle" />
201
+ <input
202
+ ref={refs.setReference as React.Ref<HTMLInputElement>}
203
+ id={inputId}
204
+ type="text"
205
+ readOnly
206
+ value={displayValue}
207
+ placeholder={placeholder}
208
+ disabled={isDisabled}
209
+ role="combobox"
210
+ aria-invalid={isInvalid}
211
+ aria-haspopup="dialog"
212
+ aria-expanded={open}
213
+ aria-controls={open ? contentId : undefined}
214
+ className={cn(
215
+ `flex h-9 w-full 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`,
216
+ hasValue ? "pr-9" : "pr-3",
217
+ isInvalid && "border-border-danger",
218
+ className
219
+ )}
220
+ {...getReferenceProps()}
221
+ />
222
+ {hasValue && !isDisabled && (
223
+ <button
224
+ type="button"
225
+ onClick={handleClear}
226
+ 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"
227
+ aria-label="Clear time"
228
+ >
229
+ <X className="h-3.5 w-3.5" />
230
+ </button>
231
+ )}
232
+
233
+ {/* Hidden input for form submission */}
234
+ {name && committedValue && <input type="hidden" name={name} value={temporalTimeToISO(committedValue, format)} />}
235
+ </div>
236
+
237
+ {/* Popover Content */}
238
+ {open && (
239
+ <FloatingPortal>
240
+ <div
241
+ ref={refs.setFloating}
242
+ id={contentId}
243
+ role="dialog"
244
+ aria-modal="true"
245
+ style={floatingStyles}
246
+ className="z-popover rounded-xl border border-border-normal bg-bg-normal p-4 shadow-lg"
247
+ {...getFloatingProps()}
248
+ >
249
+ <div className="flex">
250
+ {/* Presets Sidebar */}
251
+ {presets && presets.length > 0 && (
252
+ <div className="mr-3 flex flex-col gap-1 border-border-normal border-r pr-3">
253
+ {presets.map((preset) => (
254
+ <button
255
+ key={preset.label}
256
+ type="button"
257
+ onClick={() => handlePresetClick(preset)}
258
+ className={cn(
259
+ `rounded-md px-3 py-1.5 text-left text-sm transition-colors`,
260
+ `hover:bg-bg-subtle focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset`,
261
+ isPresetActive(preset) && "bg-bg-brand-subtle text-fg-brand"
262
+ )}
263
+ >
264
+ {preset.label}
265
+ </button>
266
+ ))}
267
+ </div>
268
+ )}
269
+
270
+ {/* Time Columns */}
271
+ <div className="flex flex-col gap-3">
272
+ {/* Column Headers */}
273
+ <div className="flex items-center justify-center gap-2">
274
+ <div className="w-14 text-center font-medium text-fg-subtle text-xs">Hour</div>
275
+ <div className="w-14 text-center font-medium text-fg-subtle text-xs">Min</div>
276
+ {format === "HH:mm:ss" && <div className="w-14 text-center font-medium text-fg-subtle text-xs">Sec</div>}
277
+ {clockFormat === "12h" && <div className="w-12 text-center font-medium text-fg-subtle text-xs" />}
278
+ </div>
279
+
280
+ {/* Scrollable Columns */}
281
+ <div className="flex items-start gap-2">
282
+ {/* Hours */}
283
+ <TimeColumn
284
+ values={hours}
285
+ selected={draftHour}
286
+ onSelect={setDraftHour}
287
+ formatValue={clockFormat === "12h" ? (v) => String(v) : (v) => String(v).padStart(2, "0")}
288
+ />
289
+
290
+ {/* Minutes */}
291
+ <TimeColumn values={minutes} selected={draftMinute} onSelect={setDraftMinute} />
292
+
293
+ {/* Seconds (optional) */}
294
+ {format === "HH:mm:ss" && <TimeColumn values={seconds} selected={draftSecond} onSelect={setDraftSecond} />}
295
+
296
+ {/* AM/PM (12-hour only) */}
297
+ {clockFormat === "12h" && <PeriodSelector value={draftPeriod} onChange={setDraftPeriod} />}
298
+ </div>
299
+
300
+ <Separator />
301
+
302
+ {/* Footer with Now / Cancel / Apply */}
303
+ <div className="flex items-center justify-between">
304
+ <Button type="button" variant="ghost" size="sm" onClick={handleNow}>
305
+ Now
306
+ </Button>
307
+ <div className="flex gap-2">
308
+ <Button type="button" variant="ghost" size="sm" onClick={handleCancel}>
309
+ Cancel
310
+ </Button>
311
+ <Button type="button" variant="solid" size="sm" onClick={handleApply}>
312
+ Apply
313
+ </Button>
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ </div>
319
+ </FloatingPortal>
320
+ )}
321
+ </>
322
+ );
323
+ };
@@ -0,0 +1,7 @@
1
+ export { TimePicker } from "./TimePicker";
2
+ export type {
3
+ ClockFormat,
4
+ TimeFormat,
5
+ TimePickerProps,
6
+ TimePresetOption,
7
+ } from "./timepicker.types";
@@ -0,0 +1,173 @@
1
+ import { formatTimePadded, Temporal } from "../../date-time";
2
+ import type { ClockFormat, ParsedTime, TimeFormat, TimePeriod } from "./timepicker.types";
3
+
4
+ // =============================================================================
5
+ // Temporal ↔ ParsedTime Conversion
6
+ // =============================================================================
7
+
8
+ /**
9
+ * Convert Temporal.PlainTime to ParsedTime
10
+ */
11
+ export function temporalToParsed(time: Temporal.PlainTime): ParsedTime {
12
+ return {
13
+ hour: time.hour,
14
+ minute: time.minute,
15
+ second: time.second,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Convert ParsedTime to Temporal.PlainTime
21
+ */
22
+ export function parsedToTemporal(parsed: ParsedTime): Temporal.PlainTime {
23
+ return Temporal.PlainTime.from({
24
+ hour: parsed.hour,
25
+ minute: parsed.minute,
26
+ second: parsed.second,
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Convert Temporal.PlainTime to ISO string (for form submission)
32
+ */
33
+ export function temporalTimeToISO(time: Temporal.PlainTime, format: TimeFormat = "HH:mm"): string {
34
+ if (format === "HH:mm:ss") {
35
+ return time.toString().slice(0, 8); // "HH:mm:ss"
36
+ }
37
+ return time.toString().slice(0, 5); // "HH:mm"
38
+ }
39
+
40
+ // =============================================================================
41
+ // Time Formatting
42
+ // =============================================================================
43
+
44
+ /**
45
+ * Format Temporal.PlainTime for display
46
+ */
47
+ export function formatTimeDisplay(time: Temporal.PlainTime, clockFormat: ClockFormat = "12h", format: TimeFormat = "HH:mm"): string {
48
+ if (clockFormat === "12h") {
49
+ return formatTimePadded(time);
50
+ }
51
+ // 24h format
52
+ if (format === "HH:mm:ss") {
53
+ return time.toLocaleString("en-US", {
54
+ hour: "2-digit",
55
+ minute: "2-digit",
56
+ second: "2-digit",
57
+ hour12: false,
58
+ });
59
+ }
60
+ return time.toLocaleString("en-US", {
61
+ hour: "2-digit",
62
+ minute: "2-digit",
63
+ hour12: false,
64
+ });
65
+ }
66
+
67
+ // =============================================================================
68
+ // Time Comparison
69
+ // =============================================================================
70
+
71
+ /**
72
+ * Check if time a is before time b
73
+ */
74
+ export function isTimeBefore(a: Temporal.PlainTime, b: Temporal.PlainTime): boolean {
75
+ return Temporal.PlainTime.compare(a, b) < 0;
76
+ }
77
+
78
+ /**
79
+ * Check if time a is after time b
80
+ */
81
+ export function isTimeAfter(a: Temporal.PlainTime, b: Temporal.PlainTime): boolean {
82
+ return Temporal.PlainTime.compare(a, b) > 0;
83
+ }
84
+
85
+ /**
86
+ * Check if a time is within min/max bounds
87
+ */
88
+ export function isTimeDisabled(time: Temporal.PlainTime, minTime?: Temporal.PlainTime, maxTime?: Temporal.PlainTime): boolean {
89
+ if (minTime && isTimeBefore(time, minTime)) {
90
+ return true;
91
+ }
92
+ if (maxTime && isTimeAfter(time, maxTime)) {
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ // =============================================================================
99
+ // 12-Hour / 24-Hour Conversion
100
+ // =============================================================================
101
+
102
+ /**
103
+ * Convert 24-hour hour to 12-hour format
104
+ */
105
+ export function to12Hour(hour24: number): { hour12: number; period: TimePeriod } {
106
+ if (hour24 === 0) {
107
+ return { hour12: 12, period: "AM" };
108
+ }
109
+ if (hour24 < 12) {
110
+ return { hour12: hour24, period: "AM" };
111
+ }
112
+ if (hour24 === 12) {
113
+ return { hour12: 12, period: "PM" };
114
+ }
115
+ return { hour12: hour24 - 12, period: "PM" };
116
+ }
117
+
118
+ /**
119
+ * Convert 12-hour format to 24-hour hour
120
+ */
121
+ export function to24Hour(hour12: number, period: TimePeriod): number {
122
+ if (period === "AM") {
123
+ return hour12 === 12 ? 0 : hour12;
124
+ }
125
+ return hour12 === 12 ? 12 : hour12 + 12;
126
+ }
127
+
128
+ // =============================================================================
129
+ // Time Generation
130
+ // =============================================================================
131
+
132
+ /**
133
+ * Generate array of hours for display
134
+ */
135
+ export function generateHours(clockFormat: ClockFormat): number[] {
136
+ if (clockFormat === "12h") {
137
+ return [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
138
+ }
139
+ return Array.from({ length: 24 }, (_, i) => i);
140
+ }
141
+
142
+ /**
143
+ * Generate array of minutes based on step
144
+ */
145
+ export function generateMinutes(step: number = 1): number[] {
146
+ const minutes: number[] = [];
147
+ for (let m = 0; m < 60; m += step) {
148
+ minutes.push(m);
149
+ }
150
+ return minutes;
151
+ }
152
+
153
+ /**
154
+ * Generate array of seconds
155
+ */
156
+ export function generateSeconds(): number[] {
157
+ return Array.from({ length: 60 }, (_, i) => i);
158
+ }
159
+
160
+ /**
161
+ * Get current time as Temporal.PlainTime, rounded to minute precision
162
+ * to avoid nanosecond drift causing unnecessary re-renders
163
+ */
164
+ export function getCurrentTime(): Temporal.PlainTime {
165
+ return Temporal.Now.plainTimeISO().round({ smallestUnit: "minute", roundingMode: "floor" });
166
+ }
167
+
168
+ /**
169
+ * Round time to nearest step
170
+ */
171
+ export function roundToStep(minute: number, step: number): number {
172
+ return Math.round(minute / step) * step;
173
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * TimePicker types
3
+ *
4
+ * All time values use Temporal.PlainTime at the component boundary.
5
+ * This provides type safety and semantic clarity about what kind of time is expected.
6
+ */
7
+
8
+ import type { Temporal } from "../../date-time";
9
+
10
+ // =============================================================================
11
+ // Value Types
12
+ // =============================================================================
13
+
14
+ /**
15
+ * Time value format for display and serialization
16
+ * - "HH:mm" for minute precision (default)
17
+ * - "HH:mm:ss" for second precision
18
+ */
19
+ export type TimeFormat = "HH:mm" | "HH:mm:ss";
20
+
21
+ /**
22
+ * Clock format for display
23
+ * - "12h" shows AM/PM selector
24
+ * - "24h" shows 00-23 hours
25
+ */
26
+ export type ClockFormat = "12h" | "24h";
27
+
28
+ // =============================================================================
29
+ // Preset Options
30
+ // =============================================================================
31
+
32
+ export type TimePresetOption = {
33
+ label: string;
34
+ value: Temporal.PlainTime;
35
+ };
36
+
37
+ // =============================================================================
38
+ // Props
39
+ // =============================================================================
40
+
41
+ export type TimePickerProps = {
42
+ /** Controlled value */
43
+ value?: Temporal.PlainTime | undefined;
44
+
45
+ /** Default value for uncontrolled mode */
46
+ defaultValue?: Temporal.PlainTime | undefined;
47
+
48
+ /** Called when time changes */
49
+ onChange?: (time: Temporal.PlainTime | undefined) => void;
50
+
51
+ /** Time format for the value (affects seconds visibility) */
52
+ format?: TimeFormat;
53
+
54
+ /** Clock display format */
55
+ clockFormat?: ClockFormat;
56
+
57
+ /** Minute step interval (default: 1) */
58
+ minuteStep?: 1 | 5 | 10 | 15 | 30;
59
+
60
+ /** Minimum selectable time */
61
+ minTime?: Temporal.PlainTime;
62
+
63
+ /** Maximum selectable time */
64
+ maxTime?: Temporal.PlainTime;
65
+
66
+ /** Preset options (shown in sidebar) */
67
+ presets?: TimePresetOption[];
68
+
69
+ /** Placeholder text */
70
+ placeholder?: string;
71
+
72
+ /** Input name for form submission */
73
+ name?: string;
74
+
75
+ /** Whether the picker is disabled */
76
+ isDisabled?: boolean;
77
+
78
+ /** Error state */
79
+ isInvalid?: boolean;
80
+
81
+ /** Additional class name for the trigger input */
82
+ className?: string;
83
+ };
84
+
85
+ // =============================================================================
86
+ // Internal Types
87
+ // =============================================================================
88
+
89
+ /**
90
+ * Parsed time value for internal use
91
+ */
92
+ export type ParsedTime = {
93
+ hour: number; // 0-23
94
+ minute: number; // 0-59
95
+ second: number; // 0-59
96
+ };
97
+
98
+ /**
99
+ * AM/PM period
100
+ */
101
+ export type TimePeriod = "AM" | "PM";