@payglocal_ui/flux-ui 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,41 +12,235 @@ 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)}`;
28
65
  }
29
- function displayDate(ymd: string) {
30
- const p = parseYMD(ymd);
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}`;
87
+ }
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;
40
134
  className?: string;
135
+ /** Earliest selectable date, `YYYY-MM-DD`. Days before it are struck out. */
41
136
  min?: string;
137
+ /**
138
+ * Latest selectable date, `YYYY-MM-DD`.
139
+ *
140
+ * Its absence is why a feature ended up hand-rolling a whole date chip to
141
+ * enforce an upper bound on Apply instead — an error after the fact, where
142
+ * the calendar could have said so before the click.
143
+ */
144
+ max?: string;
42
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
+ );
43
230
  }
44
231
 
45
232
  /* ─── DatePicker ─────────────────────────────────────────────────────────── */
46
- export function DatePicker({ value, onChange, placeholder = "Select date", className, min, label }: DatePickerProps) {
233
+ export function DatePicker({ value, onChange, placeholder = "Select date", className, min, max, label, showTime = false, showNow = true }: DatePickerProps) {
47
234
  const today = new Date();
48
- 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;
49
244
 
50
245
  const [open, setOpen] = useState(false);
51
246
  const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
@@ -66,13 +261,18 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
66
261
  // eslint-disable-next-line react-hooks/exhaustive-deps
67
262
  }, [value]);
68
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
+
69
270
  /* Compute fixed position from trigger rect */
70
271
  function openPanel() {
71
272
  if (!triggerRef.current) return;
72
273
  const trigger = triggerRef.current;
73
274
  const vw = window.innerWidth;
74
275
  const vh = window.innerHeight;
75
- const PANEL_H = 340; // approx height
76
276
 
77
277
  // Scroll trigger into view so panel can appear next to it (avoids panel far from input in scrollable forms)
78
278
  trigger.scrollIntoView({ block: "center", behavior: "auto" });
@@ -84,25 +284,29 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
84
284
  // Horizontal: align left edge, clamp so it doesn't go off screen
85
285
  let left = rect.left;
86
286
  if (left + PANEL_W > vw - 8) left = vw - PANEL_W - 8;
287
+ if (left < 8) left = 8;
87
288
 
88
289
  // Vertical: prefer below trigger; if not enough room open above
89
290
  let top = rect.bottom + 6;
90
291
  if (top + PANEL_H > vh - 8) top = rect.top - PANEL_H - 6;
292
+ if (top < 8) top = 8;
91
293
 
92
294
  setPanelPos({ top, left });
93
295
  setOpen(true);
94
296
  });
95
297
  }
96
298
 
299
+ function closePanel() {
300
+ setOpen(false); setYearMenu(false); setMonthMenu(false);
301
+ }
302
+
97
303
  /* Close on outside click */
98
304
  useEffect(() => {
99
305
  if (!open) return;
100
306
  function handler(e: MouseEvent) {
101
307
  const inTrigger = triggerRef.current?.contains(e.target as Node);
102
308
  const inPanel = panelRef.current?.contains(e.target as Node);
103
- if (!inTrigger && !inPanel) {
104
- setOpen(false); setYearMenu(false); setMonthMenu(false);
105
- }
309
+ if (!inTrigger && !inPanel) closePanel();
106
310
  }
107
311
  document.addEventListener("mousedown", handler);
108
312
  return () => document.removeEventListener("mousedown", handler);
@@ -123,10 +327,13 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
123
327
  const firstDay = firstDayOf(viewYear, viewMonth);
124
328
  const prevTotal = daysInMonth(viewYear, viewMonth === 0 ? 11 : viewMonth - 1);
125
329
  const minParsed = parseYMD(min ?? "");
330
+ const maxParsed = parseYMD(max ?? "");
126
331
 
127
332
  function isDisabled(y: number, m: number, d: number) {
128
- if (!minParsed) return false;
129
- return new Date(y, m, d) < new Date(minParsed.y, minParsed.m, minParsed.d);
333
+ const day = new Date(y, m, d);
334
+ if (minParsed && day < new Date(minParsed.y, minParsed.m, minParsed.d)) return true;
335
+ if (maxParsed && day > new Date(maxParsed.y, maxParsed.m, maxParsed.d)) return true;
336
+ return false;
130
337
  }
131
338
 
132
339
  type Cell = { d: number; m: number; y: number; current: boolean };
@@ -147,16 +354,242 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
147
354
 
148
355
  const years = Array.from({ length: 15 }, (_, i) => today.getFullYear() - 2 + i);
149
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
+
150
409
  function selectDay(cell: Cell) {
151
410
  if (!cell.current) { setViewYear(cell.y); setViewMonth(cell.m); }
152
411
  if (cell.current && isDisabled(cell.y, cell.m, cell.d)) return;
153
- onChange(toYMD(cell.y, cell.m, cell.d));
154
- 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();
155
427
  }
156
428
 
157
429
  const isToday = (c: Cell) => c.d === today.getDate() && c.m === today.getMonth() && c.y === today.getFullYear();
158
430
  const isSelected = (c: Cell) => !!parsed && c.d === parsed.d && c.m === parsed.m && c.y === parsed.y;
159
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
+
160
593
  /* ── Calendar panel (portalled) ── */
161
594
  const panel = (
162
595
  <AnimatePresence>
@@ -174,116 +607,28 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
174
607
  left: panelPos.left,
175
608
  width: PANEL_W,
176
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",
177
619
  backgroundColor: "var(--popover)",
178
620
  boxShadow: "0 16px 40px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.07)",
179
621
  }}
180
622
  >
181
- {/* Header */}
182
- <div className="flex items-center justify-between px-4 pt-4 pb-3">
183
- <button onClick={prevMonth}
184
- className="w-8 h-8 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors">
185
- <ChevronLeft className="w-4 h-4" />
186
- </button>
187
-
188
- <div className="flex items-center gap-1">
189
- {/* Month */}
190
- <div className="relative">
191
- <button onClick={() => { setMonthMenu(o => !o); setYearMenu(false); }}
192
- 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">
193
- {MONTHS[viewMonth].slice(0, 3)}
194
- <ChevronDown className="w-3 h-3 text-gray-400" />
195
- </button>
196
- <AnimatePresence>
197
- {monthMenu && (
198
- <motion.div
199
- initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }}
200
- transition={{ duration: 0.12 }}
201
- 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"
202
- >
203
- {MONTHS.map((mn, mi) => (
204
- <button key={mn} onClick={() => { setViewMonth(mi); setMonthMenu(false); }}
205
- className="w-full px-3 py-2 text-left text-[13px] text-foreground transition-colors hover:bg-muted"
206
- style={{ fontWeight: mi === viewMonth ? 600 : 400, color: mi === viewMonth ? PRIMARY : undefined }}
207
- >
208
- {mn}
209
- </button>
210
- ))}
211
- </motion.div>
212
- )}
213
- </AnimatePresence>
214
- </div>
215
-
216
- {/* Year */}
217
- <div className="relative">
218
- <button onClick={() => { setYearMenu(o => !o); setMonthMenu(false); }}
219
- 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">
220
- {viewYear}
221
- <ChevronDown className="w-3 h-3 text-gray-400" />
222
- </button>
223
- <AnimatePresence>
224
- {yearMenu && (
225
- <motion.div
226
- initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }}
227
- transition={{ duration: 0.12 }}
228
- 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"
229
- >
230
- {years.map(yr => (
231
- <button key={yr} onClick={() => { setViewYear(yr); setYearMenu(false); }}
232
- className="w-full px-3 py-2 text-left text-[13px] text-foreground transition-colors hover:bg-muted"
233
- style={{ fontWeight: yr === viewYear ? 600 : 400, color: yr === viewYear ? PRIMARY : undefined }}
234
- >
235
- {yr}
236
- </button>
237
- ))}
238
- </motion.div>
239
- )}
240
- </AnimatePresence>
241
- </div>
242
- </div>
243
-
244
- <button onClick={nextMonth}
245
- className="w-8 h-8 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors">
246
- <ChevronRight className="w-4 h-4" />
247
- </button>
248
- </div>
249
-
250
- {/* Day headers — explicit grid: Tailwind grid-cols-7 can be dropped from CSS output for portalled nodes */}
251
- <div
252
- className="px-3 pb-1"
253
- style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
254
- >
255
- {DAYS.map((d) => (
256
- <div key={d} className="py-1 text-center text-[11.5px] font-semibold text-muted-foreground">
257
- {d}
258
- </div>
259
- ))}
260
- </div>
261
-
262
- {/* Day grid */}
263
- <div
264
- className="gap-y-0.5 px-3 pb-4"
265
- style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
266
- >
267
- {cells.map((cell, i) => {
268
- const selected = isSelected(cell);
269
- const tod = isToday(cell);
270
- const disabled = cell.current && isDisabled(cell.y, cell.m, cell.d);
271
- return (
272
- <button key={i} onClick={() => selectDay(cell)} disabled={disabled}
273
- className={cn(
274
- "h-9 w-9 mx-auto rounded-full text-[13px] font-medium flex items-center justify-center transition-all",
275
- selected && "text-white font-semibold",
276
- !selected && tod && "font-semibold",
277
- !selected && !tod && cell.current && !disabled && "text-gray-800 hover:bg-gray-100",
278
- !selected && !cell.current && "text-gray-300 hover:bg-gray-50",
279
- disabled && "opacity-30 cursor-not-allowed",
280
- )}
281
- style={selected ? { background: PRIMARY } : tod ? { background: `${PRIMARY}18`, color: PRIMARY } : {}}>
282
- {cell.d}
283
- </button>
284
- );
285
- })}
286
- </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}
287
632
  </motion.div>
288
633
  )}
289
634
  </AnimatePresence>
@@ -297,7 +642,7 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
297
642
  <button
298
643
  ref={triggerRef}
299
644
  type="button"
300
- onClick={() => open ? setOpen(false) : openPanel()}
645
+ onClick={() => open ? closePanel() : openPanel()}
301
646
  className={cn(
302
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",
303
648
  open ? "border-ring ring-2 ring-ring/20" : "hover:border-muted-foreground/45",
@@ -305,7 +650,7 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
305
650
  >
306
651
  <CalendarDays className="size-[1.125rem] shrink-0 text-muted-foreground" />
307
652
  <span className={cn("flex-1", value ? "text-foreground" : "text-muted-foreground")}>
308
- {value ? displayDate(value) : placeholder}
653
+ {value ? displayValue(value, withTime, showSecond) : placeholder}
309
654
  </span>
310
655
  <ChevronDown className={cn("size-[1.125rem] shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
311
656
  </button>