sid-ui 0.0.0-stage → 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.
- package/README.md +54 -2
- package/package.json +51 -4
- package/src/brand/BrandFooter.tsx +45 -0
- package/src/brand/BrandHeading.tsx +24 -0
- package/src/brand/BrandLockup.tsx +47 -0
- package/src/brand/JoinedLogo.tsx +67 -0
- package/src/brand/JoinedMark.tsx +76 -0
- package/src/brand/geometry.ts +26 -0
- package/src/brand/index.ts +11 -0
- package/src/brand/name.ts +5 -0
- package/src/brand/paint.tsx +56 -0
- package/src/components/Action.tsx +44 -0
- package/src/components/AddressSelector.tsx +99 -0
- package/src/components/BarChart.tsx +132 -0
- package/src/components/Calendar.tsx +284 -0
- package/src/components/CalendarAgenda.tsx +87 -0
- package/src/components/CalendarMonth.tsx +223 -0
- package/src/components/CalendarSchedule.tsx +210 -0
- package/src/components/Card.tsx +80 -0
- package/src/components/Charts.tsx +64 -0
- package/src/components/Chat.tsx +35 -0
- package/src/components/CitySelector.tsx +81 -0
- package/src/components/Clock.tsx +196 -0
- package/src/components/CodeInput.tsx +183 -0
- package/src/components/Container.tsx +20 -0
- package/src/components/Content.tsx +50 -0
- package/src/components/Data.tsx +43 -0
- package/src/components/DataInput.tsx +107 -0
- package/src/components/DateField.tsx +162 -0
- package/src/components/DonutChart.tsx +123 -0
- package/src/components/Drawer.tsx +110 -0
- package/src/components/Feedback.tsx +37 -0
- package/src/components/FileUploader.tsx +377 -0
- package/src/components/FunnelChart.tsx +83 -0
- package/src/components/Glyph.tsx +330 -0
- package/src/components/GoogleSignIn.tsx +67 -0
- package/src/components/GridSystem.tsx +131 -0
- package/src/components/HeatmapCalendar.tsx +167 -0
- package/src/components/KanbanBoard.tsx +421 -0
- package/src/components/Kpi.tsx +61 -0
- package/src/components/LayoutPrimitives.tsx +86 -0
- package/src/components/Lists.tsx +19 -0
- package/src/components/LocationSelector.tsx +49 -0
- package/src/components/MessageList.tsx +80 -0
- package/src/components/Nav.tsx +69 -0
- package/src/components/Navigation.tsx +52 -0
- package/src/components/Notification.tsx +115 -0
- package/src/components/NotificationTrigger.tsx +248 -0
- package/src/components/Overlay.tsx +50 -0
- package/src/components/Page.tsx +143 -0
- package/src/components/PageTabs.tsx +28 -0
- package/src/components/PickerShell.tsx +138 -0
- package/src/components/PillNav.tsx +194 -0
- package/src/components/Primitives.tsx +30 -0
- package/src/components/Rating.tsx +78 -0
- package/src/components/Responsive.tsx +128 -0
- package/src/components/ResponsiveFrame.tsx +122 -0
- package/src/components/Segment.tsx +190 -0
- package/src/components/Sparkline.tsx +52 -0
- package/src/components/StateSelector.tsx +39 -0
- package/src/components/Sticky.tsx +95 -0
- package/src/components/Table.tsx +344 -0
- package/src/components/ThemeToggle.tsx +48 -0
- package/src/components/Tile.tsx +23 -0
- package/src/components/TimeColumns.tsx +155 -0
- package/src/components/TimeDial.tsx +260 -0
- package/src/components/TimeField.tsx +196 -0
- package/src/components/TimeInput.tsx +213 -0
- package/src/components/TimeSlots.tsx +83 -0
- package/src/components/Timeline.tsx +111 -0
- package/src/components/TokenDemo.tsx +378 -0
- package/src/components/Tree.tsx +283 -0
- package/src/components/TrendChart.tsx +211 -0
- package/src/components/breakpoints.ts +42 -0
- package/src/components/calendarTypes.ts +26 -0
- package/src/components/chartMath.ts +125 -0
- package/src/components/chartParts.tsx +116 -0
- package/src/components/codeInputRules.ts +58 -0
- package/src/components/date.ts +87 -0
- package/src/components/hooks.ts +53 -0
- package/src/components/index.ts +195 -0
- package/src/components/kanban.ts +78 -0
- package/src/components/motion.ts +78 -0
- package/src/components/places-search.tsx +73 -0
- package/src/components/places.ts +209 -0
- package/src/components/size.ts +3 -0
- package/src/components/time.ts +83 -0
- package/src/components/useFileDrop.ts +104 -0
- package/src/places/geoapify.ts +129 -0
- package/src/styles/astryx.css +5 -0
- package/src/styles/components/brand.css +98 -0
- package/src/styles/components/calendar.css +680 -0
- package/src/styles/components/charts.css +659 -0
- package/src/styles/components/code-input.css +233 -0
- package/src/styles/components/google.css +11 -0
- package/src/styles/components/message-list.css +153 -0
- package/src/styles/components/notification.css +350 -0
- package/src/styles/components/picker.css +114 -0
- package/src/styles/components/pill-nav.css +179 -0
- package/src/styles/components/responsive.css +419 -0
- package/src/styles/components/sticky.css +23 -0
- package/src/styles/components/table.css +267 -0
- package/src/styles/components/time.css +526 -0
- package/src/styles/components/timeline.css +426 -0
- package/src/styles/components/tree.css +269 -0
- package/src/styles/components.css +423 -0
- package/src/styles/joined.css +4 -0
- package/src/styles/theme.css +79 -0
- package/src/styles/tokens.css +338 -0
- package/src/theme/JoinedProvider.tsx +50 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/joined.d.ts +11 -0
- package/src/theme/joined.js +569 -0
- package/src/theme/joined.theme.ts +74 -0
- package/src/theme/joined.variants.d.ts +16 -0
- package/src/theme/theme.css +786 -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
|
+
}
|