@payglocal_ui/flux-ui 0.3.0 → 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.
- package/dist/index.cjs +429 -212
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -2
- package/dist/index.d.ts +43 -2
- package/dist/index.js +456 -239
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/copyable-cell-tooltip.test.tsx +81 -0
- package/src/__tests__/date-picker-show-time.test.tsx +189 -0
- package/src/__tests__/picker-in-dialog.test.tsx +140 -0
- package/src/copyable-cell.tsx +65 -26
- package/src/date-picker.tsx +459 -126
- package/src/index.ts +1 -1
- package/src/time-picker.tsx +95 -66
package/src/date-picker.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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}-${
|
|
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
|
-
|
|
30
|
-
|
|
88
|
+
|
|
89
|
+
function displayValue(value: string, showTime: boolean, showSecond: boolean) {
|
|
90
|
+
const p = parseValue(value);
|
|
31
91
|
if (!p) return "";
|
|
32
|
-
|
|
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 =
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
{/*
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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 ?
|
|
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 ?
|
|
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>
|