devnonla-ui 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/LICENSE +21 -0
- package/README.md +140 -0
- package/package.json +66 -0
- package/src/alert/Alert.tsx +109 -0
- package/src/app/App.tsx +36 -0
- package/src/button/Button.tsx +278 -0
- package/src/button/ButtonCopy.tsx +60 -0
- package/src/calendar/Calendar.tsx +133 -0
- package/src/chat/AgentPanel.tsx +203 -0
- package/src/chat/common/bgTasks.ts +41 -0
- package/src/chat/common/sse.ts +156 -0
- package/src/chat/common/types.ts +43 -0
- package/src/chat/common/useAgentStream.ts +351 -0
- package/src/chat/common/utils.ts +73 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/message-ui/ChatAgentMessage.tsx +29 -0
- package/src/chat/message-ui/ChatError.tsx +24 -0
- package/src/chat/message-ui/ChatInput.tsx +162 -0
- package/src/chat/message-ui/ChatMarkdown.tsx +128 -0
- package/src/chat/message-ui/ChatMarkdownTable.tsx +45 -0
- package/src/chat/message-ui/ChatSpinner.tsx +5 -0
- package/src/chat/message-ui/ChatThinking.tsx +61 -0
- package/src/chat/message-ui/ChatUserMessage.tsx +72 -0
- package/src/chat/message-ui/ChatWelcome.tsx +70 -0
- package/src/chat/message-ui/MermaidBlock.tsx +266 -0
- package/src/chat/message-ui/mermaidFence.ts +55 -0
- package/src/chat/message-ui/sanitizeMermaid.ts +135 -0
- package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +36 -0
- package/src/chat/tool-ui/BackgroundTasksBar.tsx +152 -0
- package/src/chat/tool-ui/CallAgentToolUI.tsx +168 -0
- package/src/chat/tool-ui/ChatToolCall.tsx +77 -0
- package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +49 -0
- package/src/chat/tool-ui/ReadSkillToolUI.tsx +80 -0
- package/src/chat/tool-ui/RunJsToolUI.tsx +106 -0
- package/src/chat/tool-ui/ToolUiBadge.tsx +10 -0
- package/src/chat/tool-ui/ToolUiTrailing.tsx +24 -0
- package/src/chat/tool-ui/WebFetchToolUI.tsx +53 -0
- package/src/chat/tool-ui/registry.ts +29 -0
- package/src/chat/tool-ui/types.ts +22 -0
- package/src/checkbox/Checkbox.tsx +50 -0
- package/src/codeblock/CodeBlock.tsx +76 -0
- package/src/codeblock/highlight.ts +159 -0
- package/src/colorpicker/ColorPicker.tsx +45 -0
- package/src/datepicker/DatePicker.tsx +481 -0
- package/src/desktop/DesktopHeader.tsx +92 -0
- package/src/desktop/DesktopIcon.tsx +34 -0
- package/src/desktop/DesktopStage.tsx +6 -0
- package/src/desktop/DesktopWindow.tsx +504 -0
- package/src/desktop/MeadowDesktop.tsx +15 -0
- package/src/desktop/MeadowShell.tsx +12 -0
- package/src/desktop/MeadowWallpaper.tsx +5 -0
- package/src/desktop/assets/desktop-meadow.jpg +0 -0
- package/src/desktop/assets.d.ts +9 -0
- package/src/desktop/surface.ts +1 -0
- package/src/drawer/Drawer.tsx +125 -0
- package/src/dropdown/ContextMenu.tsx +97 -0
- package/src/dropdown/Dropdown.tsx +139 -0
- package/src/dropdown/menuClasses.ts +9 -0
- package/src/empty/Empty.tsx +47 -0
- package/src/form/Form.tsx +95 -0
- package/src/form/FormItem.tsx +145 -0
- package/src/form/common/context.ts +13 -0
- package/src/form/common/enum.ts +20 -0
- package/src/form/common/rules.ts +68 -0
- package/src/form/common/types.ts +169 -0
- package/src/form/index.ts +35 -0
- package/src/form/partials/FormItemHelps.tsx +31 -0
- package/src/form/variants/FieldCheckbox.tsx +20 -0
- package/src/form/variants/FieldColor.tsx +63 -0
- package/src/form/variants/FieldDateTime.tsx +25 -0
- package/src/form/variants/FieldHidden.tsx +9 -0
- package/src/form/variants/FieldInput.tsx +24 -0
- package/src/form/variants/FieldNumber.tsx +28 -0
- package/src/form/variants/FieldObject.tsx +29 -0
- package/src/form/variants/FieldRadio.tsx +40 -0
- package/src/form/variants/FieldRepeater.tsx +62 -0
- package/src/form/variants/FieldRepeaterItem.tsx +64 -0
- package/src/form/variants/FieldSelect.tsx +27 -0
- package/src/form/variants/FieldSelectMultiple.tsx +32 -0
- package/src/form/variants/FieldSelectRemote.tsx +66 -0
- package/src/form/variants/FieldSwitch.tsx +17 -0
- package/src/form/variants/FieldTextarea.tsx +25 -0
- package/src/form/variants/FieldTime.tsx +25 -0
- package/src/form-layout/FormLayout.tsx +74 -0
- package/src/icon/FluentIcon.tsx +28 -0
- package/src/icon/fluent.ts +75 -0
- package/src/index.ts +214 -0
- package/src/input/Input.tsx +371 -0
- package/src/input/SearchInput.tsx +73 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/placement.ts +44 -0
- package/src/lib/sizes.ts +117 -0
- package/src/lib/surface.ts +8 -0
- package/src/menu/Menu.tsx +98 -0
- package/src/message/message.tsx +214 -0
- package/src/modal/Modal.tsx +277 -0
- package/src/pagination/Pagination.tsx +103 -0
- package/src/popconfirm/Popconfirm.tsx +134 -0
- package/src/popover/Popover.tsx +116 -0
- package/src/scroll/OverlayScroll.tsx +141 -0
- package/src/segmented/Segmented.tsx +67 -0
- package/src/select/Select.tsx +319 -0
- package/src/skeleton/Skeleton.tsx +47 -0
- package/src/spin/Spin.tsx +160 -0
- package/src/styles.css +897 -0
- package/src/switch/Switch.tsx +58 -0
- package/src/table/Table.tsx +387 -0
- package/src/tag/Tag.tsx +109 -0
- package/src/theme.ts +131 -0
- package/src/timepicker/TimePicker.tsx +751 -0
- package/src/tooltip/Tooltip.tsx +59 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
2
|
+
import {
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
type FocusEvent,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
type MouseEvent,
|
|
7
|
+
type PointerEvent,
|
|
8
|
+
Fragment,
|
|
9
|
+
forwardRef,
|
|
10
|
+
useCallback,
|
|
11
|
+
useEffect,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { useAppConfig } from "../app/App";
|
|
16
|
+
import { cn } from "../lib/cn";
|
|
17
|
+
import { type PopperPlacement, placementToRadix } from "../lib/placement";
|
|
18
|
+
import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlStatusClass, getSizeTokens } from "../lib/sizes";
|
|
19
|
+
import { glassOverlayClass } from "../lib/surface";
|
|
20
|
+
|
|
21
|
+
export type TimeValue = { hours: number; minutes: number; seconds: number };
|
|
22
|
+
|
|
23
|
+
export type TimePickerProps = {
|
|
24
|
+
/** 24h string (`HH:mm` / `HH:mm:ss`) or a Date (time-of-day is used). */
|
|
25
|
+
value?: string | Date | null;
|
|
26
|
+
defaultValue?: string | Date | null;
|
|
27
|
+
onChange?: (time: string | null, parts: TimeValue | null) => void;
|
|
28
|
+
size?: ControlSize;
|
|
29
|
+
status?: "error" | "warning";
|
|
30
|
+
allowClear?: boolean;
|
|
31
|
+
/** `HH:mm:ss` shows a seconds column. */
|
|
32
|
+
format?: "HH:mm" | "HH:mm:ss";
|
|
33
|
+
use12Hours?: boolean;
|
|
34
|
+
hourStep?: number;
|
|
35
|
+
minuteStep?: number;
|
|
36
|
+
secondStep?: number;
|
|
37
|
+
placeholder?: string;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
className?: string;
|
|
40
|
+
style?: CSSProperties;
|
|
41
|
+
popupClassName?: string;
|
|
42
|
+
placement?: PopperPlacement;
|
|
43
|
+
id?: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
autoFocus?: boolean;
|
|
46
|
+
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
|
|
47
|
+
onFocus?: (e: FocusEvent<HTMLInputElement>) => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type Part = "h" | "m" | "s" | "a";
|
|
51
|
+
|
|
52
|
+
function pad(n: number) {
|
|
53
|
+
return String(n).padStart(2, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clampPart(n: number, max: number) {
|
|
57
|
+
if (!Number.isFinite(n)) return 0;
|
|
58
|
+
return Math.min(max, Math.max(0, Math.trunc(n)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stepWrap(current: number, dir: 1 | -1, max: number, step: number) {
|
|
62
|
+
const count = Math.max(1, Math.floor((max + 1) / step));
|
|
63
|
+
const idx = Math.round(current / step);
|
|
64
|
+
return (((idx + dir) % count) + count) % count * step;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function to12(hours: number): { hour: number; pm: boolean } {
|
|
68
|
+
const pm = hours >= 12;
|
|
69
|
+
const hour = hours % 12 === 0 ? 12 : hours % 12;
|
|
70
|
+
return { hour, pm };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function from12(hour: number, pm: boolean) {
|
|
74
|
+
const h = hour === 12 ? 0 : hour;
|
|
75
|
+
return h + (pm ? 12 : 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatDisplay(parts: TimeValue, showSeconds: boolean, use12Hours: boolean) {
|
|
79
|
+
if (use12Hours) {
|
|
80
|
+
const { hour, pm } = to12(parts.hours);
|
|
81
|
+
const core = showSeconds ? `${pad(hour)}:${pad(parts.minutes)}:${pad(parts.seconds)}` : `${pad(hour)}:${pad(parts.minutes)}`;
|
|
82
|
+
return `${core} ${pm ? "PM" : "AM"}`;
|
|
83
|
+
}
|
|
84
|
+
return showSeconds ? `${pad(parts.hours)}:${pad(parts.minutes)}:${pad(parts.seconds)}` : `${pad(parts.hours)}:${pad(parts.minutes)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatValue(parts: TimeValue, showSeconds: boolean) {
|
|
88
|
+
return showSeconds ? `${pad(parts.hours)}:${pad(parts.minutes)}:${pad(parts.seconds)}` : `${pad(parts.hours)}:${pad(parts.minutes)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function nowParts(): TimeValue {
|
|
92
|
+
const d = new Date();
|
|
93
|
+
return { hours: d.getHours(), minutes: d.getMinutes(), seconds: d.getSeconds() };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseTime(value: string | Date | null | undefined): TimeValue | null {
|
|
97
|
+
if (value == null || value === "") return null;
|
|
98
|
+
if (value instanceof Date) {
|
|
99
|
+
if (Number.isNaN(value.getTime())) return null;
|
|
100
|
+
return { hours: value.getHours(), minutes: value.getMinutes(), seconds: value.getSeconds() };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const raw = value.trim();
|
|
104
|
+
if (!raw) return null;
|
|
105
|
+
|
|
106
|
+
const ampmMatch = raw.match(/\s*([AaPp])\.?m?\.?\s*$/i);
|
|
107
|
+
let body = raw;
|
|
108
|
+
let pm: boolean | undefined;
|
|
109
|
+
if (ampmMatch) {
|
|
110
|
+
pm = ampmMatch[1].toLowerCase() === "p";
|
|
111
|
+
body = raw.slice(0, ampmMatch.index).trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let h: number | undefined;
|
|
115
|
+
let m: number | undefined;
|
|
116
|
+
let s: number | undefined;
|
|
117
|
+
|
|
118
|
+
if (body.includes(":")) {
|
|
119
|
+
const bits = body.split(":");
|
|
120
|
+
h = Number(bits[0]);
|
|
121
|
+
m = Number(bits[1] ?? 0);
|
|
122
|
+
s = Number(bits[2] ?? 0);
|
|
123
|
+
} else {
|
|
124
|
+
const digits = body.replace(/\D/g, "");
|
|
125
|
+
if (digits.length < 1 || digits.length > 6) return null;
|
|
126
|
+
if (digits.length <= 2) {
|
|
127
|
+
h = Number(digits);
|
|
128
|
+
m = 0;
|
|
129
|
+
s = 0;
|
|
130
|
+
} else if (digits.length <= 4) {
|
|
131
|
+
h = Number(digits.slice(0, digits.length - 2));
|
|
132
|
+
m = Number(digits.slice(-2));
|
|
133
|
+
s = 0;
|
|
134
|
+
} else {
|
|
135
|
+
h = Number(digits.slice(0, digits.length - 4));
|
|
136
|
+
m = Number(digits.slice(-4, -2));
|
|
137
|
+
s = Number(digits.slice(-2));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (![h, m, s].every((n) => Number.isFinite(n))) return null;
|
|
142
|
+
let hours = clampPart(h!, 23);
|
|
143
|
+
const minutes = clampPart(m!, 59);
|
|
144
|
+
const seconds = clampPart(s!, 59);
|
|
145
|
+
|
|
146
|
+
if (pm != null) {
|
|
147
|
+
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
|
148
|
+
hours = from12(hour12, pm);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { hours, minutes, seconds };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function partFromCaret(caret: number, showSeconds: boolean, use12Hours: boolean): Part {
|
|
155
|
+
const i = Math.max(0, caret);
|
|
156
|
+
if (i <= 2) return "h";
|
|
157
|
+
if (i <= 5) return "m";
|
|
158
|
+
if (showSeconds && i <= 8) return "s";
|
|
159
|
+
if (use12Hours) return "a";
|
|
160
|
+
return showSeconds ? "s" : "m";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ClockIcon({ size }: { size: number }) {
|
|
164
|
+
return (
|
|
165
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden className="shrink-0 opacity-55">
|
|
166
|
+
<circle cx="12" cy="12" r="8.25" stroke="currentColor" strokeWidth="1.75" />
|
|
167
|
+
<path d="M12 7.5V12L15.2 14.2" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
168
|
+
</svg>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ClearIcon() {
|
|
173
|
+
return (
|
|
174
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
175
|
+
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
176
|
+
</svg>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function Chevron({ dir }: { dir: "up" | "down" | "left" }) {
|
|
181
|
+
return (
|
|
182
|
+
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
183
|
+
{dir === "up" ? (
|
|
184
|
+
<path d="M4 10L8 6L12 10" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
185
|
+
) : dir === "down" ? (
|
|
186
|
+
<path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
187
|
+
) : (
|
|
188
|
+
<path d="M10 4L6 8L10 12" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
189
|
+
)}
|
|
190
|
+
</svg>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function useHoldRepeat(onStep: (dir: 1 | -1) => void) {
|
|
195
|
+
const onStepRef = useRef(onStep);
|
|
196
|
+
onStepRef.current = onStep;
|
|
197
|
+
const delayRef = useRef(0);
|
|
198
|
+
const intervalRef = useRef(0);
|
|
199
|
+
|
|
200
|
+
const stop = useCallback(() => {
|
|
201
|
+
window.clearTimeout(delayRef.current);
|
|
202
|
+
window.clearInterval(intervalRef.current);
|
|
203
|
+
delayRef.current = 0;
|
|
204
|
+
intervalRef.current = 0;
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
const start = useCallback(
|
|
208
|
+
(dir: 1 | -1) => {
|
|
209
|
+
stop();
|
|
210
|
+
onStepRef.current(dir);
|
|
211
|
+
delayRef.current = window.setTimeout(() => {
|
|
212
|
+
intervalRef.current = window.setInterval(() => onStepRef.current(dir), 75);
|
|
213
|
+
}, 380);
|
|
214
|
+
},
|
|
215
|
+
[stop],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
useEffect(() => stop, [stop]);
|
|
219
|
+
return { start, stop };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function StepperButton({
|
|
223
|
+
dir,
|
|
224
|
+
label,
|
|
225
|
+
onStep,
|
|
226
|
+
size = 100,
|
|
227
|
+
}: {
|
|
228
|
+
dir: 1 | -1;
|
|
229
|
+
label: string;
|
|
230
|
+
onStep: (dir: 1 | -1) => void;
|
|
231
|
+
size?: number;
|
|
232
|
+
}) {
|
|
233
|
+
const hold = useHoldRepeat(onStep);
|
|
234
|
+
|
|
235
|
+
const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
238
|
+
hold.start(dir);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
tabIndex={-1}
|
|
245
|
+
aria-label={label}
|
|
246
|
+
className={cn(
|
|
247
|
+
"flex h-10 items-center justify-center rounded-[6px] text-muted-foreground transition-colors",
|
|
248
|
+
"hover:bg-foreground/5 hover:text-foreground active:bg-foreground/8",
|
|
249
|
+
)}
|
|
250
|
+
style={{ width: size }}
|
|
251
|
+
onPointerDown={onPointerDown}
|
|
252
|
+
onPointerUp={hold.stop}
|
|
253
|
+
onPointerCancel={hold.stop}
|
|
254
|
+
>
|
|
255
|
+
<Chevron dir={dir === 1 ? "up" : "down"} />
|
|
256
|
+
</button>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
type ListOption = { value: number; label: string };
|
|
261
|
+
|
|
262
|
+
function rangeOptions(max: number, step: number): ListOption[] {
|
|
263
|
+
const out: ListOption[] = [];
|
|
264
|
+
for (let n = 0; n <= max; n += step) out.push({ value: n, label: pad(n) });
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const HOUR_OPTIONS = rangeOptions(23, 1);
|
|
269
|
+
const HOUR12_OPTIONS: ListOption[] = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((n) => ({
|
|
270
|
+
value: n,
|
|
271
|
+
label: pad(n),
|
|
272
|
+
}));
|
|
273
|
+
const MINUTE_OPTIONS = rangeOptions(55, 5);
|
|
274
|
+
const PERIOD_OPTIONS: ListOption[] = [
|
|
275
|
+
{ value: 0, label: "AM" },
|
|
276
|
+
{ value: 1, label: "PM" },
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
function PickGrid({
|
|
280
|
+
options,
|
|
281
|
+
value,
|
|
282
|
+
columns,
|
|
283
|
+
onSelect,
|
|
284
|
+
}: {
|
|
285
|
+
options: ListOption[];
|
|
286
|
+
value: number;
|
|
287
|
+
columns: number;
|
|
288
|
+
onSelect: (value: number) => void;
|
|
289
|
+
}) {
|
|
290
|
+
return (
|
|
291
|
+
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
|
292
|
+
{options.map((opt) => {
|
|
293
|
+
const selected = opt.value === value;
|
|
294
|
+
return (
|
|
295
|
+
<button
|
|
296
|
+
key={opt.value}
|
|
297
|
+
type="button"
|
|
298
|
+
tabIndex={-1}
|
|
299
|
+
className={cn(
|
|
300
|
+
"flex h-11 items-center justify-center rounded-md tabular-nums text-[13.5px] font-normal transition-colors",
|
|
301
|
+
selected ? "bg-foreground/8 text-foreground" : "text-foreground hover:bg-foreground/5",
|
|
302
|
+
)}
|
|
303
|
+
onClick={() => onSelect(opt.value)}
|
|
304
|
+
>
|
|
305
|
+
{opt.label}
|
|
306
|
+
</button>
|
|
307
|
+
);
|
|
308
|
+
})}
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function Colon({ size }: { size: number }) {
|
|
314
|
+
return (
|
|
315
|
+
<span
|
|
316
|
+
className="flex w-5 items-center justify-center select-none text-[36px] font-semibold leading-none text-muted-foreground"
|
|
317
|
+
style={{ height: size }}
|
|
318
|
+
aria-hidden
|
|
319
|
+
>
|
|
320
|
+
:
|
|
321
|
+
</span>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function StepperBoard({
|
|
326
|
+
columns,
|
|
327
|
+
}: {
|
|
328
|
+
columns: {
|
|
329
|
+
key: string;
|
|
330
|
+
label: string;
|
|
331
|
+
value: string;
|
|
332
|
+
active: boolean;
|
|
333
|
+
wide?: boolean;
|
|
334
|
+
onStep: (dir: 1 | -1) => void;
|
|
335
|
+
onActivate: () => void;
|
|
336
|
+
onPick: () => void;
|
|
337
|
+
}[];
|
|
338
|
+
}) {
|
|
339
|
+
const acc = useRef<Record<string, number>>({});
|
|
340
|
+
// Fill the 300×220 body: 2 cols ≈ 100px; 3+ cols scale down to fit.
|
|
341
|
+
const size = columns.length <= 2 ? 100 : columns.length === 3 ? 80 : 68;
|
|
342
|
+
const fontSize = columns.length <= 2 ? 41 : columns.length === 3 ? 34 : 29;
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<div className="flex items-center">
|
|
346
|
+
{columns.map((col, i) => (
|
|
347
|
+
<Fragment key={col.key}>
|
|
348
|
+
{i > 0 ? (
|
|
349
|
+
<div className="flex flex-col items-center gap-1.5" aria-hidden>
|
|
350
|
+
<div className="h-10" />
|
|
351
|
+
<Colon size={size} />
|
|
352
|
+
<div className="h-10" />
|
|
353
|
+
</div>
|
|
354
|
+
) : null}
|
|
355
|
+
<div
|
|
356
|
+
role="group"
|
|
357
|
+
aria-label={col.label}
|
|
358
|
+
className="flex flex-col items-center gap-1.5"
|
|
359
|
+
onPointerDown={col.onActivate}
|
|
360
|
+
onWheel={(e) => {
|
|
361
|
+
acc.current[col.key] = (acc.current[col.key] ?? 0) + e.deltaY;
|
|
362
|
+
if (Math.abs(acc.current[col.key]) < 28) return;
|
|
363
|
+
col.onStep(acc.current[col.key] > 0 ? -1 : 1);
|
|
364
|
+
acc.current[col.key] = 0;
|
|
365
|
+
}}
|
|
366
|
+
>
|
|
367
|
+
<StepperButton dir={1} label={`Increase ${col.label}`} onStep={col.onStep} size={size} />
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
tabIndex={-1}
|
|
371
|
+
aria-label={`Choose ${col.label}`}
|
|
372
|
+
className={cn(
|
|
373
|
+
"flex cursor-pointer items-center justify-center rounded-[6px] font-semibold leading-none tracking-tight transition-colors",
|
|
374
|
+
col.wide ? "text-[23px]" : "tabular-nums",
|
|
375
|
+
col.active ? "bg-foreground/6 text-foreground" : "text-foreground hover:bg-foreground/5",
|
|
376
|
+
)}
|
|
377
|
+
style={{ width: size, height: size, fontSize: col.wide ? undefined : fontSize }}
|
|
378
|
+
onClick={col.onPick}
|
|
379
|
+
>
|
|
380
|
+
{col.value}
|
|
381
|
+
</button>
|
|
382
|
+
<StepperButton dir={-1} label={`Decrease ${col.label}`} onStep={col.onStep} size={size} />
|
|
383
|
+
</div>
|
|
384
|
+
</Fragment>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export const TimePicker = forwardRef<HTMLInputElement, TimePickerProps>(function TimePicker(
|
|
391
|
+
{
|
|
392
|
+
value,
|
|
393
|
+
defaultValue,
|
|
394
|
+
onChange,
|
|
395
|
+
size,
|
|
396
|
+
status,
|
|
397
|
+
allowClear = true,
|
|
398
|
+
format = "HH:mm",
|
|
399
|
+
use12Hours = false,
|
|
400
|
+
hourStep = 1,
|
|
401
|
+
minuteStep = 1,
|
|
402
|
+
secondStep = 1,
|
|
403
|
+
placeholder = "Select time",
|
|
404
|
+
disabled,
|
|
405
|
+
className,
|
|
406
|
+
style,
|
|
407
|
+
popupClassName,
|
|
408
|
+
placement = "right",
|
|
409
|
+
id,
|
|
410
|
+
name,
|
|
411
|
+
autoFocus,
|
|
412
|
+
onBlur,
|
|
413
|
+
onFocus,
|
|
414
|
+
},
|
|
415
|
+
ref,
|
|
416
|
+
) {
|
|
417
|
+
const app = useAppConfig();
|
|
418
|
+
const showSeconds = format === "HH:mm:ss";
|
|
419
|
+
const controlled = value !== undefined;
|
|
420
|
+
const [inner, setInner] = useState<TimeValue | null>(() => parseTime(defaultValue));
|
|
421
|
+
const selected = controlled ? parseTime(value) : inner;
|
|
422
|
+
const selectedKey = selected ? formatValue(selected, true) : "";
|
|
423
|
+
const [open, setOpen] = useState(false);
|
|
424
|
+
const [text, setText] = useState(() => (selected ? formatDisplay(selected, showSeconds, use12Hours) : ""));
|
|
425
|
+
const [focused, setFocused] = useState(false);
|
|
426
|
+
const [activePart, setActivePart] = useState<Part>("h");
|
|
427
|
+
const [picking, setPicking] = useState<Part | null>(null);
|
|
428
|
+
const [fallback, setFallback] = useState<TimeValue>(nowParts);
|
|
429
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
430
|
+
const fieldRef = useRef<HTMLDivElement | null>(null);
|
|
431
|
+
const tok = getSizeTokens(size);
|
|
432
|
+
const fieldStyle = controlFieldStyle(size);
|
|
433
|
+
const { side, align } = placementToRadix(placement);
|
|
434
|
+
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
if (focused) return;
|
|
437
|
+
setText(selected ? formatDisplay(selected, showSeconds, use12Hours) : "");
|
|
438
|
+
}, [selectedKey, focused, showSeconds, use12Hours, selected]);
|
|
439
|
+
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
if (!autoFocus) return;
|
|
442
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
443
|
+
}, [autoFocus]);
|
|
444
|
+
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
if (open && !selectedKey) setFallback(nowParts());
|
|
447
|
+
if (!open) setPicking(null);
|
|
448
|
+
}, [open, selectedKey]);
|
|
449
|
+
|
|
450
|
+
const commit = useCallback(
|
|
451
|
+
(next: TimeValue | null) => {
|
|
452
|
+
if (!controlled) setInner(next);
|
|
453
|
+
onChange?.(next ? formatValue(next, showSeconds) : null, next);
|
|
454
|
+
setText(next ? formatDisplay(next, showSeconds, use12Hours) : "");
|
|
455
|
+
},
|
|
456
|
+
[controlled, onChange, showSeconds, use12Hours],
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const wall = selected ?? fallback;
|
|
460
|
+
|
|
461
|
+
const applyStep = (part: Part, dir: 1 | -1) => {
|
|
462
|
+
if (disabled) return;
|
|
463
|
+
const next: TimeValue = { ...wall };
|
|
464
|
+
if (part === "h") {
|
|
465
|
+
if (use12Hours) {
|
|
466
|
+
const { hour, pm } = to12(next.hours);
|
|
467
|
+
const stepped = stepWrap(hour === 12 ? 0 : hour, dir, 11, 1);
|
|
468
|
+
const hour12 = stepped === 0 ? 12 : stepped;
|
|
469
|
+
next.hours = from12(hour12, pm);
|
|
470
|
+
} else {
|
|
471
|
+
next.hours = stepWrap(next.hours, dir, 23, hourStep);
|
|
472
|
+
}
|
|
473
|
+
} else if (part === "m") {
|
|
474
|
+
next.minutes = stepWrap(next.minutes, dir, 59, minuteStep);
|
|
475
|
+
} else if (part === "s") {
|
|
476
|
+
next.seconds = stepWrap(next.seconds, dir, 59, secondStep);
|
|
477
|
+
} else {
|
|
478
|
+
next.hours = (next.hours + 12) % 24;
|
|
479
|
+
}
|
|
480
|
+
setActivePart(part);
|
|
481
|
+
commit(next);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const applyPartValue = (part: Part, n: number) => {
|
|
485
|
+
if (disabled) return;
|
|
486
|
+
const next: TimeValue = { ...wall };
|
|
487
|
+
if (part === "h") {
|
|
488
|
+
next.hours = use12Hours ? from12(n, to12(next.hours).pm) : n;
|
|
489
|
+
} else if (part === "m") {
|
|
490
|
+
next.minutes = n;
|
|
491
|
+
} else if (part === "s") {
|
|
492
|
+
next.seconds = n;
|
|
493
|
+
} else {
|
|
494
|
+
next.hours = from12(to12(next.hours).hour, n === 1);
|
|
495
|
+
}
|
|
496
|
+
setActivePart(part);
|
|
497
|
+
commit(next);
|
|
498
|
+
setPicking(null);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const togglePick = (part: Part) => {
|
|
502
|
+
setActivePart(part);
|
|
503
|
+
setPicking((p) => (p === part ? null : part));
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const setRefs = (node: HTMLInputElement | null) => {
|
|
507
|
+
inputRef.current = node;
|
|
508
|
+
if (typeof ref === "function") ref(node);
|
|
509
|
+
else if (ref) ref.current = node;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const syncPartFromCaret = (el: HTMLInputElement) => {
|
|
513
|
+
setActivePart(partFromCaret(el.selectionStart ?? 0, showSeconds, use12Hours));
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const clear = (e: MouseEvent) => {
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
e.stopPropagation();
|
|
519
|
+
commit(null);
|
|
520
|
+
inputRef.current?.focus();
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
524
|
+
if (e.key === "ArrowUp") {
|
|
525
|
+
e.preventDefault();
|
|
526
|
+
applyStep(activePart, 1);
|
|
527
|
+
setOpen(true);
|
|
528
|
+
} else if (e.key === "ArrowDown") {
|
|
529
|
+
e.preventDefault();
|
|
530
|
+
applyStep(activePart, -1);
|
|
531
|
+
setOpen(true);
|
|
532
|
+
} else if (e.key === "Enter") {
|
|
533
|
+
const parsed = parseTime(text);
|
|
534
|
+
commit(parsed);
|
|
535
|
+
setOpen(false);
|
|
536
|
+
inputRef.current?.blur();
|
|
537
|
+
} else if (e.key === "Escape") {
|
|
538
|
+
if (picking) {
|
|
539
|
+
setPicking(null);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
setText(selected ? formatDisplay(selected, showSeconds, use12Hours) : "");
|
|
543
|
+
setOpen(false);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const dismissLock = useRef(false);
|
|
548
|
+
const dismissLockTimer = useRef(0);
|
|
549
|
+
|
|
550
|
+
const openPicker = () => {
|
|
551
|
+
if (disabled) return;
|
|
552
|
+
dismissLock.current = true;
|
|
553
|
+
window.clearTimeout(dismissLockTimer.current);
|
|
554
|
+
dismissLockTimer.current = window.setTimeout(() => {
|
|
555
|
+
dismissLock.current = false;
|
|
556
|
+
}, 300);
|
|
557
|
+
setOpen(true);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
useEffect(() => () => window.clearTimeout(dismissLockTimer.current), []);
|
|
561
|
+
|
|
562
|
+
const { hour: hour12, pm } = to12(wall.hours);
|
|
563
|
+
|
|
564
|
+
const pickOptions = picking === "h" ? (use12Hours ? HOUR12_OPTIONS : HOUR_OPTIONS) : picking === "a" ? PERIOD_OPTIONS : picking === "m" || picking === "s" ? MINUTE_OPTIONS : null;
|
|
565
|
+
const pickValue = picking === "h" ? (use12Hours ? hour12 : wall.hours) : picking === "m" ? wall.minutes : picking === "s" ? wall.seconds : picking === "a" ? (pm ? 1 : 0) : 0;
|
|
566
|
+
const pickColumns = picking === "h" && !use12Hours ? 6 : picking === "a" ? 2 : 4;
|
|
567
|
+
|
|
568
|
+
return (
|
|
569
|
+
<PopoverPrimitive.Root
|
|
570
|
+
open={open}
|
|
571
|
+
onOpenChange={(v) => {
|
|
572
|
+
if (disabled) return;
|
|
573
|
+
if (!v && dismissLock.current) return;
|
|
574
|
+
setOpen(v);
|
|
575
|
+
}}
|
|
576
|
+
>
|
|
577
|
+
<PopoverPrimitive.Anchor asChild>
|
|
578
|
+
<div
|
|
579
|
+
ref={fieldRef}
|
|
580
|
+
onPointerDown={() => openPicker()}
|
|
581
|
+
className={cn(
|
|
582
|
+
"group/timepicker inline-flex w-full cursor-pointer items-center gap-2",
|
|
583
|
+
controlFieldSurface,
|
|
584
|
+
controlFieldTransition,
|
|
585
|
+
controlFieldFocusBorder,
|
|
586
|
+
open && "border-brand",
|
|
587
|
+
controlStatusClass(status),
|
|
588
|
+
disabled && "cursor-not-allowed opacity-45",
|
|
589
|
+
className,
|
|
590
|
+
)}
|
|
591
|
+
style={{ ...fieldStyle, paddingRight: tok.paddingInlineIconEnd, ...style }}
|
|
592
|
+
>
|
|
593
|
+
<span className="inline-flex shrink-0 text-muted-foreground">
|
|
594
|
+
<ClockIcon size={tok.icon} />
|
|
595
|
+
</span>
|
|
596
|
+
<input
|
|
597
|
+
ref={setRefs}
|
|
598
|
+
id={id}
|
|
599
|
+
name={name}
|
|
600
|
+
disabled={disabled}
|
|
601
|
+
placeholder={placeholder}
|
|
602
|
+
autoComplete="off"
|
|
603
|
+
spellCheck={false}
|
|
604
|
+
inputMode="numeric"
|
|
605
|
+
aria-haspopup="dialog"
|
|
606
|
+
aria-expanded={open}
|
|
607
|
+
aria-label={placeholder}
|
|
608
|
+
value={text}
|
|
609
|
+
onChange={(e) => {
|
|
610
|
+
const raw = e.target.value;
|
|
611
|
+
setText(raw);
|
|
612
|
+
const parsed = parseTime(raw);
|
|
613
|
+
if (parsed) {
|
|
614
|
+
if (!controlled) setInner(parsed);
|
|
615
|
+
onChange?.(formatValue(parsed, showSeconds), parsed);
|
|
616
|
+
}
|
|
617
|
+
}}
|
|
618
|
+
onFocus={(e) => {
|
|
619
|
+
setFocused(true);
|
|
620
|
+
openPicker();
|
|
621
|
+
syncPartFromCaret(e.currentTarget);
|
|
622
|
+
onFocus?.(e);
|
|
623
|
+
}}
|
|
624
|
+
onBlur={(e) => {
|
|
625
|
+
setFocused(false);
|
|
626
|
+
const parsed = parseTime(text);
|
|
627
|
+
commit(parsed);
|
|
628
|
+
onBlur?.(e);
|
|
629
|
+
}}
|
|
630
|
+
onKeyDown={handleKeyDown}
|
|
631
|
+
onKeyUp={(e) => syncPartFromCaret(e.currentTarget)}
|
|
632
|
+
onClick={(e) => syncPartFromCaret(e.currentTarget)}
|
|
633
|
+
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-foreground placeholder:text-quaternary-foreground outline-none disabled:cursor-not-allowed"
|
|
634
|
+
style={{ fontSize: fieldStyle.fontSize, lineHeight: fieldStyle.lineHeight }}
|
|
635
|
+
/>
|
|
636
|
+
{allowClear && selected && !disabled ? (
|
|
637
|
+
<button
|
|
638
|
+
type="button"
|
|
639
|
+
tabIndex={-1}
|
|
640
|
+
aria-label="Clear"
|
|
641
|
+
className="inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground opacity-0 transition-opacity hover:bg-foreground/6 hover:text-foreground group-hover/timepicker:opacity-100 group-focus-within/timepicker:opacity-100"
|
|
642
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
643
|
+
onClick={clear}
|
|
644
|
+
>
|
|
645
|
+
<ClearIcon />
|
|
646
|
+
</button>
|
|
647
|
+
) : null}
|
|
648
|
+
</div>
|
|
649
|
+
</PopoverPrimitive.Anchor>
|
|
650
|
+
<PopoverPrimitive.Portal container={app.getPopupContainer?.()}>
|
|
651
|
+
<PopoverPrimitive.Content
|
|
652
|
+
side={side}
|
|
653
|
+
align={align}
|
|
654
|
+
sideOffset={10}
|
|
655
|
+
alignOffset={0}
|
|
656
|
+
arrowPadding={16}
|
|
657
|
+
collisionPadding={10}
|
|
658
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
659
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
660
|
+
onPointerDown={(e) => e.preventDefault()}
|
|
661
|
+
onPointerDownOutside={(e) => {
|
|
662
|
+
if (fieldRef.current?.contains(e.target as Node)) e.preventDefault();
|
|
663
|
+
}}
|
|
664
|
+
onFocusOutside={(e) => {
|
|
665
|
+
if (fieldRef.current?.contains(e.target as Node)) e.preventDefault();
|
|
666
|
+
}}
|
|
667
|
+
onInteractOutside={(e) => {
|
|
668
|
+
if (fieldRef.current?.contains(e.target as Node)) e.preventDefault();
|
|
669
|
+
}}
|
|
670
|
+
className={cn(
|
|
671
|
+
"w-[300px] p-0",
|
|
672
|
+
glassOverlayClass,
|
|
673
|
+
popupClassName,
|
|
674
|
+
)}
|
|
675
|
+
>
|
|
676
|
+
<div className="relative overflow-hidden rounded-[inherit]">
|
|
677
|
+
{picking && pickOptions ? (
|
|
678
|
+
<div className="relative flex h-[220px] items-center px-3 pt-8 pb-3">
|
|
679
|
+
<button
|
|
680
|
+
type="button"
|
|
681
|
+
tabIndex={-1}
|
|
682
|
+
aria-label="Back"
|
|
683
|
+
className="absolute top-2 left-1.5 z-10 flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
|
|
684
|
+
onClick={() => setPicking(null)}
|
|
685
|
+
>
|
|
686
|
+
<Chevron dir="left" />
|
|
687
|
+
</button>
|
|
688
|
+
<div className="w-full">
|
|
689
|
+
<PickGrid options={pickOptions} value={pickValue} columns={pickColumns} onSelect={(n) => applyPartValue(picking, n)} />
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
) : (
|
|
693
|
+
<div className="flex h-[220px] flex-col items-center justify-center px-4">
|
|
694
|
+
<StepperBoard
|
|
695
|
+
columns={[
|
|
696
|
+
{
|
|
697
|
+
key: "h",
|
|
698
|
+
label: "Hours",
|
|
699
|
+
value: pad(use12Hours ? hour12 : wall.hours),
|
|
700
|
+
active: activePart === "h",
|
|
701
|
+
onStep: (dir) => applyStep("h", dir),
|
|
702
|
+
onActivate: () => setActivePart("h"),
|
|
703
|
+
onPick: () => togglePick("h"),
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
key: "m",
|
|
707
|
+
label: "Minutes",
|
|
708
|
+
value: pad(wall.minutes),
|
|
709
|
+
active: activePart === "m",
|
|
710
|
+
onStep: (dir) => applyStep("m", dir),
|
|
711
|
+
onActivate: () => setActivePart("m"),
|
|
712
|
+
onPick: () => togglePick("m"),
|
|
713
|
+
},
|
|
714
|
+
...(showSeconds
|
|
715
|
+
? [
|
|
716
|
+
{
|
|
717
|
+
key: "s",
|
|
718
|
+
label: "Seconds",
|
|
719
|
+
value: pad(wall.seconds),
|
|
720
|
+
active: activePart === "s",
|
|
721
|
+
onStep: (dir: 1 | -1) => applyStep("s", dir),
|
|
722
|
+
onActivate: () => setActivePart("s"),
|
|
723
|
+
onPick: () => togglePick("s"),
|
|
724
|
+
},
|
|
725
|
+
]
|
|
726
|
+
: []),
|
|
727
|
+
...(use12Hours
|
|
728
|
+
? [
|
|
729
|
+
{
|
|
730
|
+
key: "a",
|
|
731
|
+
label: "Period",
|
|
732
|
+
value: pm ? "PM" : "AM",
|
|
733
|
+
active: activePart === "a",
|
|
734
|
+
wide: true,
|
|
735
|
+
onStep: () => applyStep("a", 1),
|
|
736
|
+
onActivate: () => setActivePart("a"),
|
|
737
|
+
onPick: () => togglePick("a"),
|
|
738
|
+
},
|
|
739
|
+
]
|
|
740
|
+
: []),
|
|
741
|
+
]}
|
|
742
|
+
/>
|
|
743
|
+
</div>
|
|
744
|
+
)}
|
|
745
|
+
</div>
|
|
746
|
+
<PopoverPrimitive.Arrow width={12} height={7} className="fill-glass drop-shadow-[0_1px_0_var(--glass-border)]" />
|
|
747
|
+
</PopoverPrimitive.Content>
|
|
748
|
+
</PopoverPrimitive.Portal>
|
|
749
|
+
</PopoverPrimitive.Root>
|
|
750
|
+
);
|
|
751
|
+
});
|