@payglocal_ui/flux-ui 0.3.0 → 0.3.2

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.
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useState, useRef, useEffect } from "react";
3
+ import { useState, useRef, useEffect, useMemo, useCallback } from "react";
4
4
  import { createPortal } from "react-dom";
5
+ import { RemoveScroll } from "react-remove-scroll";
5
6
  import { ChevronLeft, ChevronRight, ChevronDown, CalendarDays } from "lucide-react";
6
7
  import { AnimatePresence, motion } from "framer-motion";
7
8
  import { cn } from "./utils";
@@ -11,29 +12,122 @@ const MONTHS = ["January","February","March","April","May","June",
11
12
  "July","August","September","October","November","December"];
12
13
  const DAYS = ["Su","Mo","Tu","We","Th","Fr","Sa"];
13
14
  const PRIMARY = "#0061E3";
14
- const PANEL_W = 296;
15
+
16
+ /** Width of the calendar half. The time columns are added to it. */
17
+ const CALENDAR_W = 296;
18
+ const TIME_COL_W = 58;
19
+ /** One row in a time column. */
20
+ const TIME_ITEM_H = 28;
21
+ /** Visible height of a time column, sized to sit flush with the day grid. */
22
+ const TIME_COL_H = 232;
15
23
 
16
24
  /* ─── Helpers ────────────────────────────────────────────────────────────── */
17
25
  function daysInMonth(y: number, m: number) { return new Date(y, m + 1, 0).getDate(); }
18
26
  function firstDayOf(y: number, m: number) { return new Date(y, m, 1).getDay(); }
19
-
20
- function parseYMD(s: string) {
27
+ function pad(n: number) { return String(n).padStart(2, "0"); }
28
+
29
+ /**
30
+ * A parsed picker value. `hh`/`mm`/`ss` are the 24-hour clock, and are 0 for a
31
+ * date-only value — a value carrying no time is "midnight", the same reading
32
+ * antd gives it.
33
+ */
34
+ interface Parts { y: number; m: number; d: number; hh: number; mm: number; ss: number }
35
+
36
+ /**
37
+ * Accepts `YYYY-MM-DD`, `YYYY-MM-DD HH:mm` and `YYYY-MM-DD HH:mm:ss`, so a
38
+ * picker switched to `showTime` still reads back a value written before it was,
39
+ * and `min`/`max` can be given as plain dates whatever the value carries.
40
+ */
41
+ function parseValue(s: string): Parts | null {
21
42
  if (!s) return null;
22
- const [y, m, d] = s.split("-").map(Number);
43
+ const [datePart, timePart = ""] = s.trim().split(/[ T]/);
44
+ const [y, m, d] = datePart.split("-").map(Number);
23
45
  if (!y || !m || !d) return null;
24
- return { y, m: m - 1, d };
46
+ const [hh = 0, mm = 0, ss = 0] = timePart ? timePart.split(":").map(Number) : [];
47
+ return {
48
+ y,
49
+ m: m - 1,
50
+ d,
51
+ hh: Number.isFinite(hh) ? hh : 0,
52
+ mm: Number.isFinite(mm) ? mm : 0,
53
+ ss: Number.isFinite(ss) ? ss : 0,
54
+ };
55
+ }
56
+
57
+ /** Kept as the old name so nothing that imported it has to change. */
58
+ function parseYMD(s: string) {
59
+ const p = parseValue(s);
60
+ return p ? { y: p.y, m: p.m, d: p.d } : null;
25
61
  }
62
+
26
63
  function toYMD(y: number, m: number, d: number) {
27
- return `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
64
+ return `${y}-${pad(m + 1)}-${pad(d)}`;
65
+ }
66
+
67
+ /**
68
+ * The string this picker emits. Date-only unless a time is being shown, so a
69
+ * picker without `showTime` keeps emitting exactly what it always did.
70
+ */
71
+ function formatValue(p: Parts, showTime: boolean, showSecond: boolean) {
72
+ const date = toYMD(p.y, p.m, p.d);
73
+ if (!showTime) return date;
74
+ return showSecond
75
+ ? `${date} ${pad(p.hh)}:${pad(p.mm)}:${pad(p.ss)}`
76
+ : `${date} ${pad(p.hh)}:${pad(p.mm)}`;
77
+ }
78
+
79
+ /** 12-hour, to match every other timestamp flux renders. */
80
+ function displayTime(p: Parts, showSecond: boolean) {
81
+ const period = p.hh < 12 ? "AM" : "PM";
82
+ const h12 = p.hh % 12 === 0 ? 12 : p.hh % 12;
83
+ const base = showSecond
84
+ ? `${pad(h12)}:${pad(p.mm)}:${pad(p.ss)}`
85
+ : `${pad(h12)}:${pad(p.mm)}`;
86
+ return `${base} ${period}`;
28
87
  }
29
- function displayDate(ymd: string) {
30
- const p = parseYMD(ymd);
88
+
89
+ function displayValue(value: string, showTime: boolean, showSecond: boolean) {
90
+ const p = parseValue(value);
31
91
  if (!p) return "";
32
- return `${String(p.d).padStart(2, "0")} ${MONTHS[p.m].slice(0, 3)} ${p.y}`;
92
+ const date = `${pad(p.d)} ${MONTHS[p.m].slice(0, 3)} ${p.y}`;
93
+ return showTime ? `${date}, ${displayTime(p, showSecond)}` : date;
94
+ }
95
+
96
+ /** `1`, or a step floored to at least 1 — a step of 0 would loop forever. */
97
+ function step(n: number | undefined) {
98
+ return Math.max(1, Math.floor(n ?? 1));
33
99
  }
34
100
 
35
101
  /* ─── Props ─────────────────────────────────────────────────────────────── */
102
+
103
+ /**
104
+ * `showTime`'s options, following antd's prop of the same name.
105
+ *
106
+ * Two defaults differ from antd's, both because of what flux renders elsewhere:
107
+ * `use12Hours` is **on** (antd defaults it off) because `formatDateTime` prints
108
+ * every timestamp in this library with `hour12`, and entering "23:55" to read it
109
+ * back as "11:55 PM" is the mismatch that makes a reviewer check a row twice;
110
+ * and `showSecond` is **off** (antd defaults it on) because nothing in flux
111
+ * records a second.
112
+ */
113
+ export interface DatePickerTimeOptions {
114
+ /** Hour column is 12-hour with an AM/PM column beside it. Default `true`. */
115
+ use12Hours?: boolean;
116
+ /** Add a seconds column, and put seconds in the emitted value. Default `false`. */
117
+ showSecond?: boolean;
118
+ hourStep?: number;
119
+ minuteStep?: number;
120
+ secondStep?: number;
121
+ /** `HH:mm[:ss]` used when a day is picked before any time. Default `"00:00"`. */
122
+ defaultValue?: string;
123
+ }
124
+
36
125
  interface DatePickerProps {
126
+ /**
127
+ * `YYYY-MM-DD`, or `YYYY-MM-DD HH:mm` (`HH:mm:ss` with `showSecond`) when
128
+ * `showTime` is set — the same widening antd does to its value when a time is
129
+ * shown.
130
+ */
37
131
  value: string;
38
132
  onChange: (v: string) => void;
39
133
  placeholder?: string;
@@ -49,12 +143,104 @@ interface DatePickerProps {
49
143
  */
50
144
  max?: string;
51
145
  label?: string;
146
+ /**
147
+ * Put time columns beside the calendar, so a day and the time on it are one
148
+ * control rather than two fields that can disagree.
149
+ *
150
+ * Follows antd: the panel gains Hr / Min (/ Sec) (/ AM-PM) columns and a
151
+ * footer, picking a day no longer closes the panel, and **OK** is what
152
+ * commits. `onChange` still fires on every edit — OK closes, it does not
153
+ * gate the value — so a controlled caller sees each change as it happens.
154
+ */
155
+ showTime?: boolean | DatePickerTimeOptions;
156
+ /**
157
+ * antd's `showNow`: the "Now" shortcut in the footer. Default `true` when a
158
+ * time is shown, and ignored otherwise.
159
+ */
160
+ showNow?: boolean;
161
+ }
162
+
163
+ /* ─── Time column ────────────────────────────────────────────────────────── */
164
+
165
+ /**
166
+ * One scrollable unit column.
167
+ *
168
+ * The selected row is scrolled to the **top** rather than centred, which is
169
+ * antd's behaviour and the reason each column is padded underneath: without the
170
+ * padding the last few values can never reach the top and look unreachable.
171
+ */
172
+ function TimeColumn<T extends string | number>({
173
+ label,
174
+ items,
175
+ selected,
176
+ onSelect,
177
+ render,
178
+ }: {
179
+ label: string;
180
+ items: T[];
181
+ selected: T;
182
+ onSelect: (item: T) => void;
183
+ render?: (item: T) => string;
184
+ }) {
185
+ const ref = useRef<HTMLDivElement>(null);
186
+ const index = items.indexOf(selected);
187
+
188
+ // Scrolled with the panel's own layout effect timing rather than on every
189
+ // render: a click that lands mid-scroll would otherwise fight the animation.
190
+ useEffect(() => {
191
+ const el = ref.current;
192
+ if (!el || index < 0) return;
193
+ el.scrollTo({ top: index * TIME_ITEM_H, behavior: "smooth" });
194
+ }, [index]);
195
+
196
+ return (
197
+ <div className="flex flex-col border-l border-border" style={{ width: TIME_COL_W }}>
198
+ <div className="py-1 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
199
+ {label}
200
+ </div>
201
+ <div
202
+ ref={ref}
203
+ className="overflow-y-auto"
204
+ style={{ height: TIME_COL_H, scrollbarWidth: "none", msOverflowStyle: "none" }}
205
+ >
206
+ {items.map((item) => {
207
+ const active = item === selected;
208
+ return (
209
+ <button
210
+ key={String(item)}
211
+ type="button"
212
+ onClick={() => onSelect(item)}
213
+ className={cn(
214
+ "flex w-full items-center justify-center rounded-md text-[13px] font-medium transition-colors",
215
+ active
216
+ ? "font-semibold text-white"
217
+ : "text-foreground hover:bg-muted",
218
+ )}
219
+ style={{ height: TIME_ITEM_H, background: active ? PRIMARY : undefined }}
220
+ >
221
+ {render ? render(item) : String(item)}
222
+ </button>
223
+ );
224
+ })}
225
+ {/* Lets the final value reach the top of the column. */}
226
+ <div style={{ height: TIME_COL_H - TIME_ITEM_H }} />
227
+ </div>
228
+ </div>
229
+ );
52
230
  }
53
231
 
54
232
  /* ─── DatePicker ─────────────────────────────────────────────────────────── */
55
- export function DatePicker({ value, onChange, placeholder = "Select date", className, min, max, label }: DatePickerProps) {
233
+ export function DatePicker({ value, onChange, placeholder = "Select date", className, min, max, label, showTime = false, showNow = true }: DatePickerProps) {
56
234
  const today = new Date();
57
- const parsed = parseYMD(value);
235
+ const parsed = parseValue(value);
236
+
237
+ const timeOptions: DatePickerTimeOptions = useMemo(
238
+ () => (typeof showTime === "object" ? showTime : {}),
239
+ [showTime]
240
+ );
241
+ const withTime = showTime !== false && showTime !== undefined;
242
+ const use12Hours = timeOptions.use12Hours ?? true;
243
+ const showSecond = timeOptions.showSecond ?? false;
58
244
 
59
245
  const [open, setOpen] = useState(false);
60
246
  const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
@@ -75,13 +261,18 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
75
261
  // eslint-disable-next-line react-hooks/exhaustive-deps
76
262
  }, [value]);
77
263
 
264
+ /* ── Panel size, which the time columns widen ── */
265
+ const columnCount = withTime ? (showSecond ? 3 : 2) + (use12Hours ? 1 : 0) : 0;
266
+ const PANEL_W = CALENDAR_W + columnCount * TIME_COL_W;
267
+ // Calendar body, plus the footer that only exists when a time is shown.
268
+ const PANEL_H = withTime ? 392 : 340;
269
+
78
270
  /* Compute fixed position from trigger rect */
79
271
  function openPanel() {
80
272
  if (!triggerRef.current) return;
81
273
  const trigger = triggerRef.current;
82
274
  const vw = window.innerWidth;
83
275
  const vh = window.innerHeight;
84
- const PANEL_H = 340; // approx height
85
276
 
86
277
  // Scroll trigger into view so panel can appear next to it (avoids panel far from input in scrollable forms)
87
278
  trigger.scrollIntoView({ block: "center", behavior: "auto" });
@@ -93,25 +284,29 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
93
284
  // Horizontal: align left edge, clamp so it doesn't go off screen
94
285
  let left = rect.left;
95
286
  if (left + PANEL_W > vw - 8) left = vw - PANEL_W - 8;
287
+ if (left < 8) left = 8;
96
288
 
97
289
  // Vertical: prefer below trigger; if not enough room open above
98
290
  let top = rect.bottom + 6;
99
291
  if (top + PANEL_H > vh - 8) top = rect.top - PANEL_H - 6;
292
+ if (top < 8) top = 8;
100
293
 
101
294
  setPanelPos({ top, left });
102
295
  setOpen(true);
103
296
  });
104
297
  }
105
298
 
299
+ function closePanel() {
300
+ setOpen(false); setYearMenu(false); setMonthMenu(false);
301
+ }
302
+
106
303
  /* Close on outside click */
107
304
  useEffect(() => {
108
305
  if (!open) return;
109
306
  function handler(e: MouseEvent) {
110
307
  const inTrigger = triggerRef.current?.contains(e.target as Node);
111
308
  const inPanel = panelRef.current?.contains(e.target as Node);
112
- if (!inTrigger && !inPanel) {
113
- setOpen(false); setYearMenu(false); setMonthMenu(false);
114
- }
309
+ if (!inTrigger && !inPanel) closePanel();
115
310
  }
116
311
  document.addEventListener("mousedown", handler);
117
312
  return () => document.removeEventListener("mousedown", handler);
@@ -159,16 +354,242 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
159
354
 
160
355
  const years = Array.from({ length: 15 }, (_, i) => today.getFullYear() - 2 + i);
161
356
 
357
+ /* ── The time half ── */
358
+
359
+ /**
360
+ * The time the columns show. A value with no time yet reads as `defaultValue`
361
+ * (midnight unless the caller says otherwise) so the columns always have a
362
+ * row highlighted rather than starting blank.
363
+ */
364
+ const fallback = parseValue(`2000-01-01 ${timeOptions.defaultValue ?? "00:00"}`)!;
365
+ const current: Parts = parsed ?? {
366
+ y: today.getFullYear(), m: today.getMonth(), d: today.getDate(),
367
+ hh: fallback.hh, mm: fallback.mm, ss: fallback.ss,
368
+ };
369
+
370
+ const emit = useCallback(
371
+ (next: Parts) => onChange(formatValue(next, withTime, showSecond)),
372
+ [onChange, withTime, showSecond]
373
+ );
374
+
375
+ /**
376
+ * Editing a time before a day has been picked commits today's date along with
377
+ * it — antd does the same, and the alternative is a time that silently goes
378
+ * nowhere until a day is clicked.
379
+ */
380
+ function patchTime(patch: Partial<Pick<Parts, "hh" | "mm" | "ss">>) {
381
+ emit({ ...current, ...patch });
382
+ }
383
+
384
+ const hourStep = step(timeOptions.hourStep);
385
+ const minuteStep = step(timeOptions.minuteStep);
386
+ const secondStep = step(timeOptions.secondStep);
387
+
388
+ // 12-hour runs 12, 01 … 11 rather than 01 … 12, because 12 AM is midnight and
389
+ // so is the first hour of the block — the order antd uses, and ascending by
390
+ // the underlying 24-hour value.
391
+ const hourItems = use12Hours
392
+ ? Array.from({ length: Math.ceil(12 / hourStep) }, (_, i) => (i * hourStep) % 12)
393
+ : Array.from({ length: Math.ceil(24 / hourStep) }, (_, i) => i * hourStep);
394
+ const minuteItems = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep);
395
+ const secondItems = Array.from({ length: Math.ceil(60 / secondStep) }, (_, i) => i * secondStep);
396
+
397
+ const selectedHour = use12Hours ? current.hh % 12 : current.hh;
398
+ const selectedMeridiem: "AM" | "PM" = current.hh < 12 ? "AM" : "PM";
399
+
400
+ function selectHour(h: number) {
401
+ if (!use12Hours) return patchTime({ hh: h });
402
+ patchTime({ hh: selectedMeridiem === "AM" ? h : h + 12 });
403
+ }
404
+ function selectMeridiem(p: string) {
405
+ const base = current.hh % 12;
406
+ patchTime({ hh: p === "AM" ? base : base + 12 });
407
+ }
408
+
162
409
  function selectDay(cell: Cell) {
163
410
  if (!cell.current) { setViewYear(cell.y); setViewMonth(cell.m); }
164
411
  if (cell.current && isDisabled(cell.y, cell.m, cell.d)) return;
165
- onChange(toYMD(cell.y, cell.m, cell.d));
166
- setOpen(false);
412
+ emit({ y: cell.y, m: cell.m, d: cell.d, hh: current.hh, mm: current.mm, ss: current.ss });
413
+ // With a time panel open the day is only half the answer, so the panel
414
+ // stays up and OK is what closes it. This is antd's behaviour, and without
415
+ // it the panel would shut before a time could be picked.
416
+ if (!withTime) setOpen(false);
417
+ }
418
+
419
+ function selectNow() {
420
+ const n = new Date();
421
+ if (isDisabled(n.getFullYear(), n.getMonth(), n.getDate())) return;
422
+ emit({
423
+ y: n.getFullYear(), m: n.getMonth(), d: n.getDate(),
424
+ hh: n.getHours(), mm: n.getMinutes(), ss: n.getSeconds(),
425
+ });
426
+ closePanel();
167
427
  }
168
428
 
169
429
  const isToday = (c: Cell) => c.d === today.getDate() && c.m === today.getMonth() && c.y === today.getFullYear();
170
430
  const isSelected = (c: Cell) => !!parsed && c.d === parsed.d && c.m === parsed.m && c.y === parsed.y;
171
431
 
432
+ /* ── Calendar half ── */
433
+ const calendar = (
434
+ <div style={{ width: CALENDAR_W }} className="flex flex-col">
435
+ {/* Header */}
436
+ <div className="flex items-center justify-between px-4 pt-4 pb-3">
437
+ <button type="button" onClick={prevMonth}
438
+ className="w-8 h-8 rounded-full flex items-center justify-center text-muted-foreground hover:bg-muted transition-colors">
439
+ <ChevronLeft className="w-4 h-4" />
440
+ </button>
441
+
442
+ <div className="flex items-center gap-1">
443
+ {/* Month */}
444
+ <div className="relative">
445
+ <button type="button" onClick={() => { setMonthMenu(o => !o); setYearMenu(false); }}
446
+ className="flex items-center gap-1 px-2 py-1 rounded-lg text-[14px] font-semibold text-foreground hover:bg-muted transition-colors">
447
+ {MONTHS[viewMonth].slice(0, 3)}
448
+ <ChevronDown className="w-3 h-3 text-muted-foreground" />
449
+ </button>
450
+ <AnimatePresence>
451
+ {monthMenu && (
452
+ <motion.div
453
+ initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }}
454
+ transition={{ duration: 0.12 }}
455
+ className="absolute top-full left-0 z-[10000] mt-1 max-h-[220px] min-w-[130px] overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
456
+ >
457
+ {MONTHS.map((mn, mi) => (
458
+ <button type="button" key={mn} onClick={() => { setViewMonth(mi); setMonthMenu(false); }}
459
+ className="w-full px-3 py-2 text-left text-[13px] text-foreground transition-colors hover:bg-muted"
460
+ style={{ fontWeight: mi === viewMonth ? 600 : 400, color: mi === viewMonth ? PRIMARY : undefined }}
461
+ >
462
+ {mn}
463
+ </button>
464
+ ))}
465
+ </motion.div>
466
+ )}
467
+ </AnimatePresence>
468
+ </div>
469
+
470
+ {/* Year */}
471
+ <div className="relative">
472
+ <button type="button" onClick={() => { setYearMenu(o => !o); setMonthMenu(false); }}
473
+ className="flex items-center gap-1 px-2 py-1 rounded-lg text-[14px] font-semibold text-foreground hover:bg-muted transition-colors">
474
+ {viewYear}
475
+ <ChevronDown className="w-3 h-3 text-muted-foreground" />
476
+ </button>
477
+ <AnimatePresence>
478
+ {yearMenu && (
479
+ <motion.div
480
+ initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }}
481
+ transition={{ duration: 0.12 }}
482
+ className="absolute top-full left-0 z-[10000] mt-1 max-h-[200px] min-w-[90px] overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
483
+ >
484
+ {years.map(yr => (
485
+ <button type="button" key={yr} onClick={() => { setViewYear(yr); setYearMenu(false); }}
486
+ className="w-full px-3 py-2 text-left text-[13px] text-foreground transition-colors hover:bg-muted"
487
+ style={{ fontWeight: yr === viewYear ? 600 : 400, color: yr === viewYear ? PRIMARY : undefined }}
488
+ >
489
+ {yr}
490
+ </button>
491
+ ))}
492
+ </motion.div>
493
+ )}
494
+ </AnimatePresence>
495
+ </div>
496
+ </div>
497
+
498
+ <button type="button" onClick={nextMonth}
499
+ className="w-8 h-8 rounded-full flex items-center justify-center text-muted-foreground hover:bg-muted transition-colors">
500
+ <ChevronRight className="w-4 h-4" />
501
+ </button>
502
+ </div>
503
+
504
+ {/* Day headers — explicit grid: Tailwind grid-cols-7 can be dropped from CSS output for portalled nodes */}
505
+ <div
506
+ className="px-3 pb-1"
507
+ style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
508
+ >
509
+ {DAYS.map((d) => (
510
+ <div key={d} className="py-1 text-center text-[11.5px] font-semibold text-muted-foreground">
511
+ {d}
512
+ </div>
513
+ ))}
514
+ </div>
515
+
516
+ {/* Day grid */}
517
+ <div
518
+ className="gap-y-0.5 px-3 pb-4"
519
+ style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
520
+ >
521
+ {cells.map((cell, i) => {
522
+ const selected = isSelected(cell);
523
+ const tod = isToday(cell);
524
+ const disabled = cell.current && isDisabled(cell.y, cell.m, cell.d);
525
+ return (
526
+ <button type="button" key={i} onClick={() => selectDay(cell)} disabled={disabled}
527
+ className={cn(
528
+ "h-9 w-9 mx-auto rounded-full text-[13px] font-medium flex items-center justify-center transition-all",
529
+ selected && "text-white font-semibold",
530
+ !selected && tod && "font-semibold",
531
+ !selected && !tod && cell.current && !disabled && "text-gray-800 hover:bg-gray-100",
532
+ !selected && !cell.current && "text-gray-300 hover:bg-gray-50",
533
+ disabled && "opacity-30 cursor-not-allowed",
534
+ )}
535
+ style={selected ? { background: PRIMARY } : tod ? { background: `${PRIMARY}18`, color: PRIMARY } : {}}>
536
+ {cell.d}
537
+ </button>
538
+ );
539
+ })}
540
+ </div>
541
+ </div>
542
+ );
543
+
544
+ /* ── Time half ── */
545
+ const timeColumns = withTime && (
546
+ <div className="flex pt-3">
547
+ <TimeColumn
548
+ label="Hr"
549
+ items={hourItems}
550
+ selected={selectedHour}
551
+ onSelect={selectHour}
552
+ render={(h) => pad(use12Hours && h === 0 ? 12 : h)}
553
+ />
554
+ <TimeColumn label="Min" items={minuteItems} selected={current.mm} onSelect={(m) => patchTime({ mm: m })} render={pad} />
555
+ {showSecond && (
556
+ <TimeColumn label="Sec" items={secondItems} selected={current.ss} onSelect={(s) => patchTime({ ss: s })} render={pad} />
557
+ )}
558
+ {use12Hours && (
559
+ <TimeColumn label="AM/PM" items={["AM", "PM"]} selected={selectedMeridiem} onSelect={selectMeridiem} />
560
+ )}
561
+ </div>
562
+ );
563
+
564
+ const body = (
565
+ <>
566
+ <div className="flex">
567
+ {calendar}
568
+ {timeColumns}
569
+ </div>
570
+
571
+ {/* Footer only exists alongside a time panel: with no time to pick, a day
572
+ click is the whole answer and an OK button would be a second click for
573
+ nothing. Same rule antd applies. */}
574
+ {withTime && (
575
+ <div className="flex items-center justify-between border-t border-border px-4 py-2.5">
576
+ {showNow ? (
577
+ <button type="button" onClick={selectNow}
578
+ className="rounded-md px-1 text-[13px] font-medium transition-colors hover:underline"
579
+ style={{ color: PRIMARY }}>
580
+ Now
581
+ </button>
582
+ ) : <span />}
583
+ <button type="button" onClick={closePanel}
584
+ className="rounded-lg px-3 py-1.5 text-[13px] font-semibold text-white transition-opacity hover:opacity-90"
585
+ style={{ background: PRIMARY }}>
586
+ OK
587
+ </button>
588
+ </div>
589
+ )}
590
+ </>
591
+ );
592
+
172
593
  /* ── Calendar panel (portalled) ── */
173
594
  const panel = (
174
595
  <AnimatePresence>
@@ -186,116 +607,28 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
186
607
  left: panelPos.left,
187
608
  width: PANEL_W,
188
609
  zIndex: 20000,
610
+ // A modal Radix Dialog sets `pointer-events: none` on <body> while
611
+ // it is open, and this panel is portaled to <body> — so without
612
+ // this every click on a day landed on nothing and the calendar
613
+ // looked frozen inside a Dialog or Drawer. Hit-testing is
614
+ // per-element, so re-enabling it here is enough; the page behind
615
+ // the dialog stays inert. The click still reaches the dialog's
616
+ // DismissableLayer through React's portal event propagation, so it
617
+ // is treated as inside and does not dismiss the dialog.
618
+ pointerEvents: "auto",
189
619
  backgroundColor: "var(--popover)",
190
620
  boxShadow: "0 16px 40px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.07)",
191
621
  }}
192
622
  >
193
- {/* Header */}
194
- <div className="flex items-center justify-between px-4 pt-4 pb-3">
195
- <button onClick={prevMonth}
196
- className="w-8 h-8 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors">
197
- <ChevronLeft className="w-4 h-4" />
198
- </button>
199
-
200
- <div className="flex items-center gap-1">
201
- {/* Month */}
202
- <div className="relative">
203
- <button onClick={() => { setMonthMenu(o => !o); setYearMenu(false); }}
204
- className="flex items-center gap-1 px-2 py-1 rounded-lg text-[14px] font-semibold text-gray-900 hover:bg-gray-100 transition-colors">
205
- {MONTHS[viewMonth].slice(0, 3)}
206
- <ChevronDown className="w-3 h-3 text-gray-400" />
207
- </button>
208
- <AnimatePresence>
209
- {monthMenu && (
210
- <motion.div
211
- initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }}
212
- transition={{ duration: 0.12 }}
213
- className="absolute top-full left-0 z-[10000] mt-1 max-h-[220px] min-w-[130px] overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
214
- >
215
- {MONTHS.map((mn, mi) => (
216
- <button key={mn} onClick={() => { setViewMonth(mi); setMonthMenu(false); }}
217
- className="w-full px-3 py-2 text-left text-[13px] text-foreground transition-colors hover:bg-muted"
218
- style={{ fontWeight: mi === viewMonth ? 600 : 400, color: mi === viewMonth ? PRIMARY : undefined }}
219
- >
220
- {mn}
221
- </button>
222
- ))}
223
- </motion.div>
224
- )}
225
- </AnimatePresence>
226
- </div>
227
-
228
- {/* Year */}
229
- <div className="relative">
230
- <button onClick={() => { setYearMenu(o => !o); setMonthMenu(false); }}
231
- className="flex items-center gap-1 px-2 py-1 rounded-lg text-[14px] font-semibold text-gray-900 hover:bg-gray-100 transition-colors">
232
- {viewYear}
233
- <ChevronDown className="w-3 h-3 text-gray-400" />
234
- </button>
235
- <AnimatePresence>
236
- {yearMenu && (
237
- <motion.div
238
- initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }}
239
- transition={{ duration: 0.12 }}
240
- className="absolute top-full left-0 z-[10000] mt-1 max-h-[200px] min-w-[90px] overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
241
- >
242
- {years.map(yr => (
243
- <button key={yr} onClick={() => { setViewYear(yr); setYearMenu(false); }}
244
- className="w-full px-3 py-2 text-left text-[13px] text-foreground transition-colors hover:bg-muted"
245
- style={{ fontWeight: yr === viewYear ? 600 : 400, color: yr === viewYear ? PRIMARY : undefined }}
246
- >
247
- {yr}
248
- </button>
249
- ))}
250
- </motion.div>
251
- )}
252
- </AnimatePresence>
253
- </div>
254
- </div>
255
-
256
- <button onClick={nextMonth}
257
- className="w-8 h-8 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors">
258
- <ChevronRight className="w-4 h-4" />
259
- </button>
260
- </div>
261
-
262
- {/* Day headers — explicit grid: Tailwind grid-cols-7 can be dropped from CSS output for portalled nodes */}
263
- <div
264
- className="px-3 pb-1"
265
- style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
266
- >
267
- {DAYS.map((d) => (
268
- <div key={d} className="py-1 text-center text-[11.5px] font-semibold text-muted-foreground">
269
- {d}
270
- </div>
271
- ))}
272
- </div>
273
-
274
- {/* Day grid */}
275
- <div
276
- className="gap-y-0.5 px-3 pb-4"
277
- style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
278
- >
279
- {cells.map((cell, i) => {
280
- const selected = isSelected(cell);
281
- const tod = isToday(cell);
282
- const disabled = cell.current && isDisabled(cell.y, cell.m, cell.d);
283
- return (
284
- <button key={i} onClick={() => selectDay(cell)} disabled={disabled}
285
- className={cn(
286
- "h-9 w-9 mx-auto rounded-full text-[13px] font-medium flex items-center justify-center transition-all",
287
- selected && "text-white font-semibold",
288
- !selected && tod && "font-semibold",
289
- !selected && !tod && cell.current && !disabled && "text-gray-800 hover:bg-gray-100",
290
- !selected && !cell.current && "text-gray-300 hover:bg-gray-50",
291
- disabled && "opacity-30 cursor-not-allowed",
292
- )}
293
- style={selected ? { background: PRIMARY } : tod ? { background: `${PRIMARY}18`, color: PRIMARY } : {}}>
294
- {cell.d}
295
- </button>
296
- );
297
- })}
298
- </div>
623
+ {/* The time columns are the only scrollable region this panel has, and
624
+ a portalled node is neither the lock container nor a shard of a
625
+ modal Dialog's `RemoveScroll` — so react-remove-scroll would
626
+ `preventDefault()` every wheel event over them and leave only the
627
+ rows already on screen reachable. Taking the top of the lock stack
628
+ hands scrolling back, the same way TimePicker and Radix `Select`
629
+ do. Mounted only with a time panel: the date-only panel has
630
+ nothing to scroll and should not lock the page. */}
631
+ {withTime ? <RemoveScroll allowPinchZoom>{body}</RemoveScroll> : body}
299
632
  </motion.div>
300
633
  )}
301
634
  </AnimatePresence>
@@ -309,7 +642,7 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
309
642
  <button
310
643
  ref={triggerRef}
311
644
  type="button"
312
- onClick={() => open ? setOpen(false) : openPanel()}
645
+ onClick={() => open ? closePanel() : openPanel()}
313
646
  className={cn(
314
647
  "flex h-12 min-h-12 w-full items-center gap-3 rounded-xl border border-border bg-card px-5 text-left text-[15px] shadow-sm transition-colors",
315
648
  open ? "border-ring ring-2 ring-ring/20" : "hover:border-muted-foreground/45",
@@ -317,7 +650,7 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
317
650
  >
318
651
  <CalendarDays className="size-[1.125rem] shrink-0 text-muted-foreground" />
319
652
  <span className={cn("flex-1", value ? "text-foreground" : "text-muted-foreground")}>
320
- {value ? displayDate(value) : placeholder}
653
+ {value ? displayValue(value, withTime, showSecond) : placeholder}
321
654
  </span>
322
655
  <ChevronDown className={cn("size-[1.125rem] shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
323
656
  </button>