sid-ui 0.0.0-stage → 0.1.1

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 (122) hide show
  1. package/README.md +54 -2
  2. package/package.json +51 -4
  3. package/src/brand/BrandFooter.tsx +45 -0
  4. package/src/brand/BrandHeading.tsx +24 -0
  5. package/src/brand/BrandLockup.tsx +47 -0
  6. package/src/brand/JoinedLogo.tsx +67 -0
  7. package/src/brand/JoinedMark.tsx +76 -0
  8. package/src/brand/geometry.ts +26 -0
  9. package/src/brand/index.ts +11 -0
  10. package/src/brand/name.ts +5 -0
  11. package/src/brand/paint.tsx +56 -0
  12. package/src/components/Action.tsx +44 -0
  13. package/src/components/AddressSelector.tsx +99 -0
  14. package/src/components/BarChart.tsx +132 -0
  15. package/src/components/Calendar.tsx +284 -0
  16. package/src/components/CalendarAgenda.tsx +87 -0
  17. package/src/components/CalendarMonth.tsx +223 -0
  18. package/src/components/CalendarSchedule.tsx +210 -0
  19. package/src/components/Card.tsx +80 -0
  20. package/src/components/Charts.tsx +64 -0
  21. package/src/components/Chat.tsx +35 -0
  22. package/src/components/CitySelector.tsx +81 -0
  23. package/src/components/Clock.tsx +196 -0
  24. package/src/components/CodeInput.tsx +183 -0
  25. package/src/components/Container.tsx +20 -0
  26. package/src/components/Content.tsx +50 -0
  27. package/src/components/Data.tsx +43 -0
  28. package/src/components/DataInput.tsx +107 -0
  29. package/src/components/DateField.tsx +162 -0
  30. package/src/components/DonutChart.tsx +123 -0
  31. package/src/components/Drawer.tsx +110 -0
  32. package/src/components/Feedback.tsx +37 -0
  33. package/src/components/FileUploader.tsx +377 -0
  34. package/src/components/FunnelChart.tsx +83 -0
  35. package/src/components/Glyph.tsx +330 -0
  36. package/src/components/GoogleSignIn.tsx +67 -0
  37. package/src/components/GridSystem.tsx +131 -0
  38. package/src/components/HeatmapCalendar.tsx +167 -0
  39. package/src/components/KanbanBoard.tsx +421 -0
  40. package/src/components/Kpi.tsx +61 -0
  41. package/src/components/LayoutPrimitives.tsx +86 -0
  42. package/src/components/Lists.tsx +19 -0
  43. package/src/components/LocationSelector.tsx +49 -0
  44. package/src/components/MessageList.tsx +80 -0
  45. package/src/components/Nav.tsx +69 -0
  46. package/src/components/Navigation.tsx +52 -0
  47. package/src/components/Notification.tsx +115 -0
  48. package/src/components/NotificationTrigger.tsx +248 -0
  49. package/src/components/Overlay.tsx +50 -0
  50. package/src/components/Page.tsx +143 -0
  51. package/src/components/PageTabs.tsx +28 -0
  52. package/src/components/PickerShell.tsx +138 -0
  53. package/src/components/PillNav.tsx +194 -0
  54. package/src/components/Primitives.tsx +30 -0
  55. package/src/components/Rating.tsx +78 -0
  56. package/src/components/Responsive.tsx +128 -0
  57. package/src/components/ResponsiveFrame.tsx +122 -0
  58. package/src/components/Segment.tsx +190 -0
  59. package/src/components/Sparkline.tsx +52 -0
  60. package/src/components/StateSelector.tsx +39 -0
  61. package/src/components/Sticky.tsx +95 -0
  62. package/src/components/Table.tsx +344 -0
  63. package/src/components/ThemeToggle.tsx +48 -0
  64. package/src/components/Tile.tsx +23 -0
  65. package/src/components/TimeColumns.tsx +155 -0
  66. package/src/components/TimeDial.tsx +260 -0
  67. package/src/components/TimeField.tsx +196 -0
  68. package/src/components/TimeInput.tsx +213 -0
  69. package/src/components/TimeSlots.tsx +83 -0
  70. package/src/components/Timeline.tsx +111 -0
  71. package/src/components/TokenDemo.tsx +378 -0
  72. package/src/components/Tree.tsx +283 -0
  73. package/src/components/TrendChart.tsx +211 -0
  74. package/src/components/breakpoints.ts +42 -0
  75. package/src/components/calendarTypes.ts +26 -0
  76. package/src/components/chartMath.ts +125 -0
  77. package/src/components/chartParts.tsx +116 -0
  78. package/src/components/codeInputRules.ts +58 -0
  79. package/src/components/date.ts +87 -0
  80. package/src/components/hooks.ts +53 -0
  81. package/src/components/index.ts +195 -0
  82. package/src/components/kanban.ts +78 -0
  83. package/src/components/motion.ts +78 -0
  84. package/src/components/places-search.tsx +73 -0
  85. package/src/components/places.ts +209 -0
  86. package/src/components/size.ts +3 -0
  87. package/src/components/time.ts +83 -0
  88. package/src/components/useFileDrop.ts +104 -0
  89. package/src/places/geoapify.ts +129 -0
  90. package/src/styles/astryx.css +5 -0
  91. package/src/styles/components/brand.css +98 -0
  92. package/src/styles/components/calendar.css +680 -0
  93. package/src/styles/components/charts.css +659 -0
  94. package/src/styles/components/code-input.css +233 -0
  95. package/src/styles/components/google.css +11 -0
  96. package/src/styles/components/message-list.css +153 -0
  97. package/src/styles/components/notification.css +350 -0
  98. package/src/styles/components/picker.css +114 -0
  99. package/src/styles/components/pill-nav.css +179 -0
  100. package/src/styles/components/responsive.css +419 -0
  101. package/src/styles/components/sticky.css +23 -0
  102. package/src/styles/components/table.css +267 -0
  103. package/src/styles/components/time.css +526 -0
  104. package/src/styles/components/timeline.css +426 -0
  105. package/src/styles/components/tree.css +269 -0
  106. package/src/styles/components.css +423 -0
  107. package/src/styles/joined.css +4 -0
  108. package/src/styles/theme.css +79 -0
  109. package/src/styles/tokens.css +338 -0
  110. package/src/theme/JoinedProvider.tsx +50 -0
  111. package/src/theme/index.ts +3 -0
  112. package/src/theme/joined.d.ts +11 -0
  113. package/src/theme/joined.js +569 -0
  114. package/src/theme/joined.theme.ts +74 -0
  115. package/src/theme/joined.variants.d.ts +16 -0
  116. package/src/theme/theme.css +786 -0
  117. package/styles/astryx.css +1 -0
  118. package/styles/components.css +1 -0
  119. package/styles/joined.css +1 -0
  120. package/styles/theme.css +1 -0
  121. package/styles/tokens.css +1 -0
  122. package/styles.css +1 -0
@@ -0,0 +1,260 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
4
+
5
+ import {
6
+ HOURS_PER_HALF,
7
+ MINUTES_PER_HOUR,
8
+ from12,
9
+ meridiemOf,
10
+ pad2,
11
+ parseTime,
12
+ formatTime,
13
+ to12,
14
+ type HourCycle,
15
+ type Meridiem,
16
+ type MinuteStep,
17
+ type TimeUnit,
18
+ } from "./time";
19
+
20
+ import type { ControlSize } from "./size";
21
+
22
+ /** Ring radii as a percent of the face. The 24-hour inner ring holds 00 and 13–23. */
23
+ const OUTER_RING = 38;
24
+ const INNER_RING = 25;
25
+ const RING_SPLIT = (OUTER_RING + INNER_RING) / 2;
26
+ const LABEL_STEP = 5;
27
+ const FULL_TURN = 360;
28
+ const QUARTER_TURN = 90;
29
+ const HOUR_LABELS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
30
+ const INNER_LABELS = [0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23];
31
+
32
+ export interface TimeDialProps {
33
+ value: string;
34
+ onChange?: (value: string) => void;
35
+ hourCycle?: HourCycle;
36
+ withSeconds?: boolean;
37
+ /** Minute snapping. Drag lands on any multiple; labels show every five. */
38
+ minuteStep?: MinuteStep;
39
+ size?: ControlSize;
40
+ disabled?: boolean;
41
+ /** Accessible name for the dial. */
42
+ label?: string;
43
+ }
44
+
45
+ function point(index: number, total: number, radius: number) {
46
+ const angle = (index / total) * Math.PI * 2 - Math.PI / 2;
47
+ return { x: 50 + radius * Math.cos(angle), y: 50 + radius * Math.sin(angle) };
48
+ }
49
+
50
+ /** An analog face. Tap or drag the hand; hours hand off to minutes automatically. */
51
+ export function TimeDial({
52
+ value,
53
+ onChange,
54
+ hourCycle = "12h",
55
+ withSeconds = false,
56
+ minuteStep = 5,
57
+ size = "md",
58
+ disabled,
59
+ label = "Time",
60
+ }: TimeDialProps) {
61
+ const parts = parseTime(value) ?? { h: 0, m: 0, s: 0 };
62
+ const [unit, setUnit] = useState<TimeUnit>("hour");
63
+ const [dragging, setDragging] = useState(false);
64
+ const faceRef = useRef<HTMLDivElement>(null);
65
+ const meridiem = meridiemOf(parts.h);
66
+ const inner = hourCycle === "24h" && (parts.h === 0 || parts.h > HOURS_PER_HALF);
67
+
68
+ function commit(next: Partial<typeof parts>) {
69
+ onChange?.(formatTime({ ...parts, ...next }, withSeconds));
70
+ }
71
+
72
+ function pick(clientX: number, clientY: number) {
73
+ const rect = faceRef.current?.getBoundingClientRect();
74
+ if (!rect) return;
75
+ const dx = clientX - (rect.left + rect.width / 2);
76
+ const dy = clientY - (rect.top + rect.height / 2);
77
+ const degrees = (Math.atan2(dy, dx) * 180) / Math.PI + QUARTER_TURN;
78
+ const turn = ((degrees % FULL_TURN) + FULL_TURN) % FULL_TURN;
79
+ const distance = (Math.hypot(dx, dy) / rect.width) * 100;
80
+
81
+ if (unit === "hour") {
82
+ const slot = Math.round(turn / (FULL_TURN / HOURS_PER_HALF)) % HOURS_PER_HALF;
83
+ if (hourCycle === "24h")
84
+ commit({ h: distance < RING_SPLIT ? INNER_LABELS[slot] : slot === 0 ? 12 : slot });
85
+ else commit({ h: from12(slot === 0 ? HOURS_PER_HALF : slot, meridiem) });
86
+ } else {
87
+ const snap = unit === "minute" ? minuteStep : 1;
88
+ const raw = Math.round(turn / (FULL_TURN / MINUTES_PER_HOUR) / snap) * snap;
89
+ commit(unit === "minute" ? { m: raw % MINUTES_PER_HOUR } : { s: raw % MINUTES_PER_HOUR });
90
+ }
91
+ }
92
+
93
+ function onPointerDown(event: PointerEvent<HTMLDivElement>) {
94
+ if (disabled) return;
95
+ event.currentTarget.setPointerCapture(event.pointerId);
96
+ setDragging(true);
97
+ pick(event.clientX, event.clientY);
98
+ }
99
+
100
+ function onPointerUp() {
101
+ if (!dragging) return;
102
+ setDragging(false);
103
+ if (unit === "hour") setUnit("minute");
104
+ else if (unit === "minute" && withSeconds) setUnit("second");
105
+ }
106
+
107
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
108
+ const delta = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }[event.key];
109
+ if (event.key === "Enter") {
110
+ event.preventDefault();
111
+ setUnit(unit === "hour" ? "minute" : unit === "minute" && withSeconds ? "second" : "hour");
112
+ return;
113
+ }
114
+ if (!delta) return;
115
+ event.preventDefault();
116
+ if (unit === "hour") commit({ h: parts.h + delta });
117
+ else if (unit === "minute") commit({ m: parts.m + delta * minuteStep });
118
+ else commit({ s: parts.s + delta });
119
+ }
120
+
121
+ const handTurn =
122
+ unit === "hour"
123
+ ? (parts.h % HOURS_PER_HALF) * (FULL_TURN / HOURS_PER_HALF)
124
+ : (unit === "minute" ? parts.m : parts.s) * (FULL_TURN / MINUTES_PER_HOUR);
125
+ const handLength = unit === "hour" && inner ? INNER_RING : OUTER_RING;
126
+ const active = unit === "hour" ? parts.h : unit === "minute" ? parts.m : parts.s;
127
+ const onLabel = unit !== "hour" && active % LABEL_STEP !== 0;
128
+ const hourText = hourCycle === "12h" ? pad2(to12(parts.h)) : pad2(parts.h);
129
+ const unitValue = { hour: hourText, minute: pad2(parts.m), second: pad2(parts.s) }[unit];
130
+
131
+ function meridiemButton(option: Meridiem) {
132
+ return (
133
+ <button
134
+ key={option}
135
+ type="button"
136
+ className="os-dial-meridiem"
137
+ aria-pressed={meridiem === option}
138
+ disabled={disabled}
139
+ onClick={() =>
140
+ meridiem !== option &&
141
+ commit({ h: parts.h + (option === "PM" ? HOURS_PER_HALF : -HOURS_PER_HALF) })
142
+ }
143
+ >
144
+ {option}
145
+ </button>
146
+ );
147
+ }
148
+
149
+ function unitButton(target: TimeUnit, text: string) {
150
+ return (
151
+ <button
152
+ type="button"
153
+ className="os-dial-unit"
154
+ aria-pressed={unit === target}
155
+ disabled={disabled}
156
+ onClick={() => setUnit(target)}
157
+ >
158
+ {text}
159
+ </button>
160
+ );
161
+ }
162
+
163
+ return (
164
+ <div
165
+ className={["os-dial", `os-dial-${size}`, disabled && "os-dial-disabled"]
166
+ .filter(Boolean)
167
+ .join(" ")}
168
+ >
169
+ <div className="os-dial-readout">
170
+ <div className="os-dial-digits">
171
+ {unitButton("hour", hourText)}
172
+ <span className="os-dial-colon">:</span>
173
+ {unitButton("minute", pad2(parts.m))}
174
+ {withSeconds && (
175
+ <>
176
+ <span className="os-dial-colon">:</span>
177
+ {unitButton("second", pad2(parts.s))}
178
+ </>
179
+ )}
180
+ </div>
181
+ {hourCycle === "12h" && (
182
+ <div className="os-dial-meridiems">{(["AM", "PM"] as const).map(meridiemButton)}</div>
183
+ )}
184
+ </div>
185
+
186
+ <div
187
+ ref={faceRef}
188
+ className={dragging ? "os-dial-face os-dial-dragging" : "os-dial-face"}
189
+ role="slider"
190
+ tabIndex={disabled ? -1 : 0}
191
+ aria-label={`${label}, ${unit}`}
192
+ aria-valuetext={`${unit} ${unitValue}`}
193
+ aria-valuenow={active}
194
+ aria-disabled={disabled || undefined}
195
+ onPointerDown={onPointerDown}
196
+ onPointerMove={(event) => dragging && pick(event.clientX, event.clientY)}
197
+ onPointerUp={onPointerUp}
198
+ onPointerCancel={() => setDragging(false)}
199
+ onKeyDown={onKeyDown}
200
+ >
201
+ <svg className="os-dial-hand" viewBox="0 0 100 100" aria-hidden>
202
+ <g style={{ transform: `rotate(${handTurn}deg)` }}>
203
+ <line x1="50" y1="50" x2="50" y2={50 - handLength} />
204
+ <circle className="os-dial-knob" cx="50" cy={50 - handLength} r="6.5" />
205
+ {onLabel && (
206
+ <circle className="os-dial-knob-dot" cx="50" cy={50 - handLength} r="1.3" />
207
+ )}
208
+ </g>
209
+ <circle className="os-dial-hub" cx="50" cy="50" r="1.8" />
210
+ </svg>
211
+
212
+ {unit === "hour"
213
+ ? HOUR_LABELS.map((hour, index) => {
214
+ const at = point(index, HOURS_PER_HALF, OUTER_RING);
215
+ const on = hourCycle === "12h" ? to12(parts.h) === hour : !inner && parts.h === hour;
216
+ return (
217
+ <span
218
+ key={hour}
219
+ className={on ? "os-dial-label os-dial-label-on" : "os-dial-label"}
220
+ style={{ left: `${at.x}%`, top: `${at.y}%` }}
221
+ >
222
+ {hourCycle === "24h" ? pad2(hour) : hour}
223
+ </span>
224
+ );
225
+ })
226
+ : HOUR_LABELS.map((_, index) => {
227
+ const mark = index * LABEL_STEP;
228
+ const at = point(index, HOURS_PER_HALF, OUTER_RING);
229
+ return (
230
+ <span
231
+ key={mark}
232
+ className={active === mark ? "os-dial-label os-dial-label-on" : "os-dial-label"}
233
+ style={{ left: `${at.x}%`, top: `${at.y}%` }}
234
+ >
235
+ {pad2(mark)}
236
+ </span>
237
+ );
238
+ })}
239
+ {unit === "hour" &&
240
+ hourCycle === "24h" &&
241
+ INNER_LABELS.map((hour, index) => {
242
+ const at = point(index, HOURS_PER_HALF, INNER_RING);
243
+ return (
244
+ <span
245
+ key={hour}
246
+ className={
247
+ parts.h === hour
248
+ ? "os-dial-label os-dial-label-inner os-dial-label-on"
249
+ : "os-dial-label os-dial-label-inner"
250
+ }
251
+ style={{ left: `${at.x}%`, top: `${at.y}%` }}
252
+ >
253
+ {pad2(hour)}
254
+ </span>
255
+ );
256
+ })}
257
+ </div>
258
+ </div>
259
+ );
260
+ }
@@ -0,0 +1,196 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ import { Button } from "./Action";
6
+ import { PickerShell } from "./PickerShell";
7
+ import { ChoiceSegment, NumberSegment, SegmentDivider } from "./Segment";
8
+ import {
9
+ HOURS_PER_HALF,
10
+ MERIDIEMS,
11
+ formatTime,
12
+ from12,
13
+ meridiemOf,
14
+ pad2,
15
+ parseTime,
16
+ to12,
17
+ type HourCycle,
18
+ type Meridiem,
19
+ type MinuteStep,
20
+ } from "./time";
21
+ import { TimeColumns } from "./TimeColumns";
22
+ import { TimeDial } from "./TimeDial";
23
+ import { TimeSlots, type TimeSlotsProps } from "./TimeSlots";
24
+
25
+ import type { ControlSize } from "./size";
26
+
27
+ export type TimePicker = "columns" | "dial" | "slots" | "none";
28
+
29
+ export interface TimeFieldProps {
30
+ value: string;
31
+ onChange?: (value: string) => void;
32
+ hourCycle?: HourCycle;
33
+ withSeconds?: boolean;
34
+ minuteStep?: MinuteStep;
35
+ /** What the dropdown shows. `none` makes a type-only field. */
36
+ picker?: TimePicker;
37
+ /** Passed through when `picker="slots"`. */
38
+ slots?: Pick<TimeSlotsProps, "start" | "end" | "interval" | "unavailable" | "grouped">;
39
+ size?: ControlSize;
40
+ disabled?: boolean;
41
+ error?: boolean;
42
+ label?: string;
43
+ labelledBy?: string;
44
+ }
45
+
46
+ interface Draft {
47
+ hour: number | null;
48
+ minute: number | null;
49
+ second: number | null;
50
+ meridiem: Meridiem | null;
51
+ }
52
+
53
+ function draftOf(value: string, hourCycle: HourCycle): Draft {
54
+ const parts = parseTime(value);
55
+ if (!parts) return { hour: null, minute: null, second: null, meridiem: null };
56
+ return {
57
+ hour: hourCycle === "12h" ? to12(parts.h) : parts.h,
58
+ minute: parts.m,
59
+ second: parts.s,
60
+ meridiem: meridiemOf(parts.h),
61
+ };
62
+ }
63
+
64
+ /** Type the time, or open the dropdown and pick it. Both stay in sync. */
65
+ export function TimeField({
66
+ value,
67
+ onChange,
68
+ hourCycle = "12h",
69
+ withSeconds = false,
70
+ minuteStep = 5,
71
+ picker = "columns",
72
+ slots,
73
+ size = "md",
74
+ disabled,
75
+ error,
76
+ label = "Time",
77
+ labelledBy,
78
+ }: TimeFieldProps) {
79
+ const twelve = hourCycle === "12h";
80
+ const [open, setOpen] = useState(false);
81
+ const [draft, setDraft] = useState(() => draftOf(value, hourCycle));
82
+ const [seen, setSeen] = useState(value);
83
+ if (seen !== value) {
84
+ setSeen(value);
85
+ setDraft(draftOf(value, hourCycle));
86
+ }
87
+
88
+ function update(patch: Partial<Draft>) {
89
+ const next = { ...draft, ...patch };
90
+ setDraft(next);
91
+ const empty = next.hour == null && next.minute == null && (!withSeconds || next.second == null);
92
+ if (empty) return onChange?.("");
93
+ if (next.hour == null || next.minute == null || (withSeconds && next.second == null)) return;
94
+ const h = twelve ? from12(next.hour, next.meridiem ?? "AM") : next.hour;
95
+ onChange?.(formatTime({ h, m: next.minute, s: next.second ?? 0 }, withSeconds));
96
+ }
97
+
98
+ function setNow() {
99
+ const now = new Date();
100
+ onChange?.(
101
+ formatTime({ h: now.getHours(), m: now.getMinutes(), s: now.getSeconds() }, withSeconds),
102
+ );
103
+ }
104
+
105
+ const panelProps = { value, onChange, hourCycle, withSeconds, minuteStep, size, disabled };
106
+ const panel =
107
+ picker === "none" ? undefined : (
108
+ <div className="os-picker-body">
109
+ {picker === "columns" && <TimeColumns {...panelProps} />}
110
+ {picker === "dial" && <TimeDial {...panelProps} label={label} />}
111
+ {picker === "slots" && (
112
+ <TimeSlots
113
+ {...slots}
114
+ value={value}
115
+ hourCycle={hourCycle}
116
+ size={size}
117
+ onChange={(next) => {
118
+ onChange?.(next);
119
+ setOpen(false);
120
+ }}
121
+ />
122
+ )}
123
+ {picker !== "slots" && (
124
+ <div className="os-picker-footer">
125
+ <Button label="Now" variant="ghost" size="sm" onClick={setNow} />
126
+ <Button label="Done" variant="primary" size="sm" onClick={() => setOpen(false)} />
127
+ </div>
128
+ )}
129
+ </div>
130
+ );
131
+
132
+ return (
133
+ <PickerShell
134
+ open={open}
135
+ onOpenChange={setOpen}
136
+ icon="clock"
137
+ label={label}
138
+ labelledBy={labelledBy}
139
+ size={size}
140
+ error={error}
141
+ disabled={disabled}
142
+ panel={panel}
143
+ segments={
144
+ <>
145
+ <NumberSegment
146
+ label="Hour"
147
+ value={draft.hour}
148
+ min={twelve ? 1 : 0}
149
+ max={twelve ? HOURS_PER_HALF : 23}
150
+ digits={2}
151
+ placeholder="--"
152
+ disabled={disabled}
153
+ format={twelve ? String : pad2}
154
+ onChange={(hour) => update({ hour })}
155
+ />
156
+ <SegmentDivider>:</SegmentDivider>
157
+ <NumberSegment
158
+ label="Minute"
159
+ value={draft.minute}
160
+ min={0}
161
+ max={59}
162
+ digits={2}
163
+ placeholder="--"
164
+ disabled={disabled}
165
+ onChange={(minute) => update({ minute })}
166
+ />
167
+ {withSeconds && (
168
+ <>
169
+ <SegmentDivider>:</SegmentDivider>
170
+ <NumberSegment
171
+ label="Second"
172
+ value={draft.second}
173
+ min={0}
174
+ max={59}
175
+ digits={2}
176
+ placeholder="--"
177
+ disabled={disabled}
178
+ onChange={(second) => update({ second })}
179
+ />
180
+ </>
181
+ )}
182
+ {twelve && (
183
+ <ChoiceSegment
184
+ label="AM or PM"
185
+ value={draft.meridiem}
186
+ options={MERIDIEMS}
187
+ placeholder="AM"
188
+ disabled={disabled}
189
+ onChange={(meridiem) => update({ meridiem })}
190
+ />
191
+ )}
192
+ </>
193
+ }
194
+ />
195
+ );
196
+ }
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import { useId } from "react";
4
+
5
+ import {
6
+ HOURS_PER_HALF,
7
+ MERIDIEMS,
8
+ MINUTES_PER_HOUR,
9
+ formatTime,
10
+ from12,
11
+ meridiemOf,
12
+ pad2,
13
+ parseTime,
14
+ range,
15
+ to12,
16
+ type HourCycle,
17
+ type MinuteStep,
18
+ } from "./time";
19
+ import { TimeColumns } from "./TimeColumns";
20
+ import { TimeDial } from "./TimeDial";
21
+ import { TimeField, type TimePicker } from "./TimeField";
22
+ import { TimeSlots, type TimeSlotsProps } from "./TimeSlots";
23
+
24
+ import type { FieldSize } from "./size";
25
+
26
+ export type { HourCycle, MinuteStep } from "./time";
27
+
28
+ /**
29
+ * field — type it, or open a dropdown (columns, dial, or slots).
30
+ * select — one native dropdown per unit.
31
+ * dial — an analog face you tap or drag.
32
+ * columns — scrolling wheels, always open.
33
+ * slots — a grid of bookable times.
34
+ */
35
+ export type TimeInputVariant = "field" | "select" | "dial" | "columns" | "slots";
36
+
37
+ export interface TimeInputProps {
38
+ label: string;
39
+ value: string;
40
+ onChange?: (value: string) => void;
41
+ variant?: TimeInputVariant;
42
+ /** The dropdown inside `variant="field"`. */
43
+ picker?: TimePicker;
44
+ size?: FieldSize;
45
+ hourCycle?: HourCycle;
46
+ withSeconds?: boolean;
47
+ minuteStep?: MinuteStep;
48
+ slots?: Pick<TimeSlotsProps, "start" | "end" | "interval" | "unavailable" | "grouped">;
49
+ disabled?: boolean;
50
+ error?: boolean;
51
+ helper?: string;
52
+ description?: string;
53
+ }
54
+
55
+ function TimeSelects({
56
+ value,
57
+ onChange,
58
+ hourCycle,
59
+ withSeconds,
60
+ minuteStep,
61
+ size,
62
+ disabled,
63
+ error,
64
+ labelledBy,
65
+ }: Required<Pick<TimeInputProps, "hourCycle" | "withSeconds" | "minuteStep" | "size">> &
66
+ Pick<TimeInputProps, "value" | "onChange" | "disabled" | "error"> & { labelledBy: string }) {
67
+ const parts = parseTime(value) ?? { h: 0, m: 0, s: 0 };
68
+ const twelve = hourCycle === "12h";
69
+ const cls = [
70
+ "os-control",
71
+ `os-control-${size}`,
72
+ "os-time-select",
73
+ error && "os-control-error",
74
+ disabled && "os-control-disabled",
75
+ ]
76
+ .filter(Boolean)
77
+ .join(" ");
78
+
79
+ function commit(next: Partial<typeof parts>) {
80
+ onChange?.(formatTime({ ...parts, ...next }, withSeconds));
81
+ }
82
+
83
+ return (
84
+ <div className="os-time-selects" role="group" aria-labelledby={labelledBy}>
85
+ <span className={cls}>
86
+ <select
87
+ aria-label="Hour"
88
+ disabled={disabled}
89
+ value={twelve ? to12(parts.h) : parts.h}
90
+ onChange={(e) =>
91
+ commit({
92
+ h: twelve
93
+ ? from12(Number(e.target.value), meridiemOf(parts.h))
94
+ : Number(e.target.value),
95
+ })
96
+ }
97
+ >
98
+ {(twelve ? range(1, HOURS_PER_HALF) : range(0, 23)).map((h) => (
99
+ <option key={h} value={h}>
100
+ {twelve ? h : pad2(h)}
101
+ </option>
102
+ ))}
103
+ </select>
104
+ </span>
105
+ <span className="os-segment-divider">:</span>
106
+ <span className={cls}>
107
+ <select
108
+ aria-label="Minute"
109
+ disabled={disabled}
110
+ value={parts.m}
111
+ onChange={(e) => commit({ m: Number(e.target.value) })}
112
+ >
113
+ {range(0, MINUTES_PER_HOUR - 1, minuteStep).map((m) => (
114
+ <option key={m} value={m}>
115
+ {pad2(m)}
116
+ </option>
117
+ ))}
118
+ </select>
119
+ </span>
120
+ {withSeconds && (
121
+ <>
122
+ <span className="os-segment-divider">:</span>
123
+ <span className={cls}>
124
+ <select
125
+ aria-label="Second"
126
+ disabled={disabled}
127
+ value={parts.s}
128
+ onChange={(e) => commit({ s: Number(e.target.value) })}
129
+ >
130
+ {range(0, MINUTES_PER_HOUR - 1).map((s) => (
131
+ <option key={s} value={s}>
132
+ {pad2(s)}
133
+ </option>
134
+ ))}
135
+ </select>
136
+ </span>
137
+ </>
138
+ )}
139
+ {twelve && (
140
+ <span className={cls}>
141
+ <select
142
+ aria-label="AM or PM"
143
+ disabled={disabled}
144
+ value={meridiemOf(parts.h)}
145
+ onChange={(e) =>
146
+ commit({ h: from12(to12(parts.h), e.target.value as (typeof MERIDIEMS)[number]) })
147
+ }
148
+ >
149
+ {MERIDIEMS.map((m) => (
150
+ <option key={m}>{m}</option>
151
+ ))}
152
+ </select>
153
+ </span>
154
+ )}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ /** One time value, five ways to enter it. Every variant reads and writes "HH:mm". */
160
+ export function TimeInput({
161
+ label,
162
+ value,
163
+ onChange,
164
+ variant = "field",
165
+ picker = "columns",
166
+ size = "md",
167
+ hourCycle = "12h",
168
+ withSeconds = false,
169
+ minuteStep = 5,
170
+ slots,
171
+ disabled,
172
+ error,
173
+ helper,
174
+ description,
175
+ }: TimeInputProps) {
176
+ const labelId = useId();
177
+ const shared = { value, onChange, hourCycle, withSeconds, minuteStep, size, disabled };
178
+
179
+ return (
180
+ <div className={`os-field-group os-time-input os-time-input-${variant}`}>
181
+ <span className="os-field-label" id={labelId}>
182
+ {label}
183
+ </span>
184
+ {description && <p className="os-field-description">{description}</p>}
185
+ {variant === "field" && (
186
+ <TimeField
187
+ {...shared}
188
+ picker={picker}
189
+ slots={slots}
190
+ error={error}
191
+ label={label}
192
+ labelledBy={labelId}
193
+ />
194
+ )}
195
+ {variant === "select" && <TimeSelects {...shared} error={error} labelledBy={labelId} />}
196
+ {variant === "dial" && <TimeDial {...shared} label={label} />}
197
+ {variant === "columns" && <TimeColumns {...shared} />}
198
+ {variant === "slots" && (
199
+ <TimeSlots
200
+ {...slots}
201
+ value={value}
202
+ onChange={onChange}
203
+ hourCycle={hourCycle}
204
+ size={size}
205
+ disabled={disabled}
206
+ />
207
+ )}
208
+ {helper && (
209
+ <span className={error ? "os-field-helper-error" : "os-field-helper"}>{helper}</span>
210
+ )}
211
+ </div>
212
+ );
213
+ }