@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.
- package/dist/index.cjs +3517 -747
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1247 -26
- package/dist/index.d.ts +1247 -26
- package/dist/index.js +3468 -703
- package/dist/index.js.map +1 -1
- package/package.json +14 -3
- package/src/__tests__/copyable-cell-tooltip.test.tsx +81 -0
- package/src/__tests__/data-card-list.test.tsx +74 -0
- package/src/__tests__/data-table-row-click.test.tsx +113 -0
- package/src/__tests__/date-picker-show-time.test.tsx +189 -0
- package/src/__tests__/filter-chips.test.tsx +237 -0
- package/src/__tests__/picker-in-dialog.test.tsx +140 -0
- package/src/calendar-date-chip.tsx +262 -0
- package/src/column-manager.tsx +590 -0
- package/src/copyable-cell.tsx +292 -0
- package/src/data-card-list.tsx +188 -0
- package/src/data-table-card.tsx +205 -0
- package/src/data-table.tsx +876 -144
- package/src/date-picker.tsx +473 -128
- package/src/filter-chips.tsx +1874 -0
- package/src/format-datetime.ts +170 -0
- package/src/index.ts +96 -3
- package/src/popover.tsx +24 -15
- package/src/rotating-search-input.tsx +164 -0
- package/src/tab-presets.tsx +189 -0
- 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,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
|
-
|
|
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)}`;
|
|
28
65
|
}
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
{/*
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 ?
|
|
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 ?
|
|
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>
|