@payglocal_ui/flux-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +72 -0
  2. package/src/accordion.tsx +68 -0
  3. package/src/alert.tsx +107 -0
  4. package/src/avatar-group.tsx +96 -0
  5. package/src/avatar-tag.tsx +136 -0
  6. package/src/avatar.tsx +39 -0
  7. package/src/badge.tsx +98 -0
  8. package/src/blanket.tsx +61 -0
  9. package/src/breadcrumb.tsx +119 -0
  10. package/src/button-group.tsx +218 -0
  11. package/src/button.tsx +83 -0
  12. package/src/calendar.tsx +227 -0
  13. package/src/callout.tsx +68 -0
  14. package/src/card.tsx +103 -0
  15. package/src/chart-templates.tsx +587 -0
  16. package/src/chart.tsx +379 -0
  17. package/src/checkbox-select.tsx +239 -0
  18. package/src/checkbox.tsx +54 -0
  19. package/src/code.tsx +154 -0
  20. package/src/command.tsx +77 -0
  21. package/src/country-select.tsx +242 -0
  22. package/src/currency-amount-input.tsx +72 -0
  23. package/src/data-table.tsx +378 -0
  24. package/src/date-picker.tsx +317 -0
  25. package/src/dialog.tsx +81 -0
  26. package/src/drawer.tsx +91 -0
  27. package/src/dropdown-menu.tsx +174 -0
  28. package/src/empty-state.tsx +32 -0
  29. package/src/field.tsx +243 -0
  30. package/src/flag.tsx +265 -0
  31. package/src/form.tsx +168 -0
  32. package/src/grid-flex.tsx +241 -0
  33. package/src/heading.tsx +202 -0
  34. package/src/icon-button.tsx +93 -0
  35. package/src/index.ts +332 -0
  36. package/src/inline-dialog.tsx +153 -0
  37. package/src/inline-edit.tsx +212 -0
  38. package/src/input-group.tsx +151 -0
  39. package/src/input.tsx +28 -0
  40. package/src/label.tsx +21 -0
  41. package/src/layout.tsx +119 -0
  42. package/src/link.tsx +80 -0
  43. package/src/lozenge.tsx +61 -0
  44. package/src/menu.tsx +146 -0
  45. package/src/otp-input.tsx +117 -0
  46. package/src/page-header.tsx +28 -0
  47. package/src/pagination.tsx +185 -0
  48. package/src/password-input.tsx +34 -0
  49. package/src/popover.tsx +31 -0
  50. package/src/progress-indicator.tsx +94 -0
  51. package/src/progress.tsx +95 -0
  52. package/src/radio-group.tsx +46 -0
  53. package/src/responsive.tsx +276 -0
  54. package/src/scroll-area.tsx +39 -0
  55. package/src/section-message.tsx +119 -0
  56. package/src/select.tsx +144 -0
  57. package/src/separator.tsx +26 -0
  58. package/src/side-nav.tsx +264 -0
  59. package/src/skeleton.tsx +74 -0
  60. package/src/slider.tsx +25 -0
  61. package/src/sonner.tsx +32 -0
  62. package/src/spinner.tsx +54 -0
  63. package/src/spotlight.tsx +141 -0
  64. package/src/status-badge.tsx +86 -0
  65. package/src/switch.tsx +70 -0
  66. package/src/tabs.tsx +57 -0
  67. package/src/tag.tsx +52 -0
  68. package/src/textarea.tsx +25 -0
  69. package/src/time-picker.tsx +443 -0
  70. package/src/tooltip.tsx +29 -0
  71. package/src/utils.ts +6 -0
  72. package/src/visually-hidden.tsx +25 -0
@@ -0,0 +1,378 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { cn } from "./utils";
5
+ import { TableRowSkeleton } from "./skeleton";
6
+ import { EmptyState } from "./empty-state";
7
+ import { ChevronLeft, ChevronRight } from "lucide-react";
8
+ import { useState } from "react";
9
+
10
+ export type DataTableDensity = "default" | "comfortable" | "compact";
11
+ export type DataTableHeaderStyle = "surface" | "minimal";
12
+ export type DataTableFooterSummary = "range" | "count";
13
+
14
+ /** Builds the visible page numbers including ellipsis markers */
15
+ function getPageRange(current: number, total: number): (number | "…")[] {
16
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
17
+ const pages: (number | "…")[] = [1];
18
+ if (current > 3) pages.push("…");
19
+ const lo = Math.max(2, current - 1);
20
+ const hi = Math.min(total - 1, current + 1);
21
+ for (let p = lo; p <= hi; p++) pages.push(p);
22
+ if (current < total - 2) pages.push("…");
23
+ pages.push(total);
24
+ return pages;
25
+ }
26
+
27
+ export type Column<T> = {
28
+ key: string;
29
+ header: ReactNode;
30
+ /** Table column width, e.g. `48px`, `18%`, `minmax(12rem,1fr)` (fixed layout) */
31
+ width?: string;
32
+ minWidth?: number;
33
+ maxWidth?: number;
34
+ align?: "left" | "right" | "center";
35
+ /** Allow cell text to wrap instead of truncating. */
36
+ wrap?: boolean;
37
+ /** Extra classes on `<th>` / `<td>` (e.g. wider horizontal padding per column) */
38
+ cellClassName?: string;
39
+ render: (row: T, index: number) => ReactNode;
40
+ };
41
+
42
+ interface DataTableProps<T> {
43
+ columns: Column<T>[];
44
+ data: T[];
45
+ isLoading?: boolean;
46
+ skeletonRows?: number;
47
+ emptyTitle?: string;
48
+ emptyDescription?: string;
49
+ pageSize?: number;
50
+ /** Controlled page number (1-indexed). Enables server-side pagination. */
51
+ page?: number;
52
+ /** Called when the user changes page in controlled mode. */
53
+ onPageChange?: (page: number) => void;
54
+ /** Total row count for server-side pagination (overrides data.length for page calculations). */
55
+ totalRows?: number;
56
+ className?: string;
57
+ rowKey: (row: T) => string;
58
+ /** Optional hover CTA shown on the right of every row */
59
+ rowCta?: {
60
+ label: string;
61
+ onClick?: (row: T) => void;
62
+ };
63
+ /** Row / cell vertical rhythm and horizontal gutters */
64
+ density?: DataTableDensity;
65
+ /** `auto` lets columns breathe; `fixed` uses `colgroup` hints */
66
+ tableLayout?: "auto" | "fixed";
67
+ theadClassName?: string;
68
+ headerStyle?: DataTableHeaderStyle;
69
+ /** Footer: paginated range vs simple `n items` */
70
+ footerSummary?: DataTableFooterSummary;
71
+ /** Noun after the count when `footerSummary="count"` (default singular / plural `item` / `items`). */
72
+ footerCountLabels?: { singular: string; plural: string };
73
+ /** With `density="compact"`, use tighter cell gutters (`pl-1.5 pr-2.5` vs `px-3`). Footer keeps normal horizontal padding. */
74
+ snug?: boolean;
75
+ }
76
+
77
+ export function DataTable<T>({
78
+ columns,
79
+ data,
80
+ isLoading = false,
81
+ skeletonRows = 6,
82
+ emptyTitle = "No data yet",
83
+ emptyDescription,
84
+ pageSize = 10,
85
+ page: controlledPage,
86
+ onPageChange,
87
+ totalRows,
88
+ className,
89
+ rowKey,
90
+ rowCta,
91
+ density = "default",
92
+ tableLayout = "fixed",
93
+ theadClassName,
94
+ headerStyle = "surface",
95
+ footerSummary = "range",
96
+ footerCountLabels = { singular: "item", plural: "items" },
97
+ snug = false,
98
+ }: DataTableProps<T>) {
99
+ const isControlled = controlledPage !== undefined;
100
+ const [internalPage, setInternalPage] = useState(1);
101
+ const page = isControlled ? controlledPage : internalPage;
102
+ const setPage = isControlled
103
+ ? (p: number) => onPageChange?.(p)
104
+ : (p: number) => setInternalPage(p);
105
+
106
+ const total = totalRows ?? data.length;
107
+ const totalPages = Math.ceil(total / pageSize);
108
+ const paginated = isControlled ? data : data.slice((page - 1) * pageSize, page * pageSize);
109
+
110
+ const comfortable = density === "comfortable";
111
+ const compact = density === "compact";
112
+ const compactCellPad = compact
113
+ ? snug
114
+ ? "pl-1.5 pr-2.5 py-2.5"
115
+ : "px-3 py-2.5"
116
+ : "px-4 py-3.5";
117
+ const cellPad = comfortable ? "px-5 py-4" : compactCellPad;
118
+ const headPad = comfortable ? "px-5 py-4" : compactCellPad;
119
+ /** Footer is outside the grid; do not reuse snug cell `pl-0`-style gutters here. */
120
+ const footerPad = comfortable
121
+ ? "px-5 py-4"
122
+ : compact
123
+ ? "px-4 py-2.5"
124
+ : "px-4 py-3.5";
125
+ const headText = comfortable
126
+ ? "text-[12px] font-medium text-muted-foreground tracking-normal"
127
+ : compact
128
+ ? "text-[11px] font-semibold text-muted-foreground"
129
+ : "text-[11px] font-semibold text-foreground/75 dark:text-foreground/85";
130
+ const rowCtaColWidth = compact ? 108 : 130;
131
+
132
+ return (
133
+ <div
134
+ className={cn(
135
+ "bg-card text-card-foreground rounded-xl overflow-hidden border border-border",
136
+ className
137
+ )}
138
+ >
139
+ {/* scrollbar space always reserved; thumb subtle on hover */}
140
+ <div
141
+ className={cn(
142
+ "overflow-x-auto",
143
+ "[&::-webkit-scrollbar]:h-[4px] [&::-webkit-scrollbar-track]:bg-transparent",
144
+ "[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-transparent",
145
+ "hover:[&::-webkit-scrollbar-thumb]:bg-border dark:hover:[&::-webkit-scrollbar-thumb]:bg-muted-foreground/35"
146
+ )}
147
+ style={{ scrollbarWidth: "thin", scrollbarColor: "var(--border) transparent" }}
148
+ >
149
+ <table
150
+ className={cn(tableLayout === "auto" && "min-w-[920px]")}
151
+ style={{ tableLayout, width: "100%" }}
152
+ >
153
+ {tableLayout === "fixed" && (
154
+ <colgroup>
155
+ {columns.map((col) => (
156
+ <col
157
+ key={col.key}
158
+ style={{
159
+ width: col.width ?? (col.minWidth != null ? `${col.minWidth}px` : undefined),
160
+ minWidth: col.minWidth,
161
+ maxWidth: col.maxWidth,
162
+ }}
163
+ />
164
+ ))}
165
+ {rowCta ? <col style={{ width: rowCtaColWidth }} /> : null}
166
+ </colgroup>
167
+ )}
168
+
169
+ <thead
170
+ className={cn(
171
+ headerStyle === "surface" && "bg-muted/35",
172
+ headerStyle === "minimal" && "bg-transparent",
173
+ theadClassName
174
+ )}
175
+ >
176
+ <tr
177
+ className={cn(
178
+ "border-b",
179
+ headerStyle === "surface" ? "border-border" : "border-border/70"
180
+ )}
181
+ >
182
+ {columns.map((col) => (
183
+ <th
184
+ key={col.key}
185
+ className={cn(
186
+ headPad,
187
+ headText,
188
+ "whitespace-nowrap align-middle",
189
+ col.align === "right"
190
+ ? "text-right"
191
+ : col.align === "center"
192
+ ? "text-center"
193
+ : "text-left",
194
+ col.cellClassName
195
+ )}
196
+ >
197
+ {col.header}
198
+ </th>
199
+ ))}
200
+ {rowCta ? <th className={cn(headPad, "w-[1%]")} aria-hidden /> : null}
201
+ </tr>
202
+ </thead>
203
+
204
+ <tbody>
205
+ {isLoading ? (
206
+ Array.from({ length: skeletonRows }).map((_, i) => (
207
+ <TableRowSkeleton
208
+ key={i}
209
+ cols={columns.length + (rowCta ? 1 : 0)}
210
+ density={density}
211
+ snug={snug}
212
+ />
213
+ ))
214
+ ) : paginated.length === 0 ? (
215
+ <tr>
216
+ <td colSpan={columns.length + (rowCta ? 1 : 0)}>
217
+ <EmptyState title={emptyTitle} description={emptyDescription} />
218
+ </td>
219
+ </tr>
220
+ ) : (
221
+ paginated.map((row, i) => (
222
+ <tr
223
+ key={rowKey(row)}
224
+ className={cn(
225
+ "group transition-colors duration-150 border-b border-border/60 last:border-b-0",
226
+ comfortable && "min-h-[56px]",
227
+ compact && "min-h-[44px]",
228
+ "hover:bg-muted/40 dark:hover:bg-muted/25",
229
+ rowCta &&
230
+ "hover:shadow-[0_1px_0_rgba(0,0,0,0.04)] dark:hover:shadow-none"
231
+ )}
232
+ >
233
+ {columns.map((col) => (
234
+ <td
235
+ key={col.key}
236
+ className={cn(
237
+ cellPad,
238
+ "align-middle",
239
+ comfortable
240
+ ? cn(
241
+ "text-[13px] leading-snug",
242
+ !col.wrap && "whitespace-nowrap"
243
+ )
244
+ : compact
245
+ ? cn(
246
+ "text-[13px] leading-tight",
247
+ !col.wrap && "whitespace-nowrap",
248
+ "overflow-hidden"
249
+ )
250
+ : "whitespace-nowrap overflow-hidden",
251
+ col.align === "right"
252
+ ? "text-right"
253
+ : col.align === "center"
254
+ ? "text-center"
255
+ : "text-left",
256
+ col.cellClassName
257
+ )}
258
+ >
259
+ {col.render(row, i)}
260
+ </td>
261
+ ))}
262
+
263
+ {rowCta ? (
264
+ <td
265
+ className={cn(
266
+ cellPad,
267
+ "text-left align-middle whitespace-nowrap",
268
+ comfortable
269
+ ? "pl-2 pr-5"
270
+ : compact
271
+ ? snug
272
+ ? "pl-1.5 pr-2"
273
+ : "pl-1.5 pr-3"
274
+ : "pl-3 pr-4"
275
+ )}
276
+ >
277
+ <button
278
+ type="button"
279
+ onClick={() => rowCta.onClick?.(row)}
280
+ className={cn(
281
+ "opacity-0 group-hover:opacity-100 transition-opacity duration-150 inline-flex items-center font-medium text-foreground bg-card rounded-lg border border-border hover:border-muted-foreground/50 whitespace-nowrap shadow-sm",
282
+ compact
283
+ ? "px-2.5 py-1 text-[11px]"
284
+ : "px-3 py-1.5 text-[12px]"
285
+ )}
286
+ >
287
+ {rowCta.label}
288
+ </button>
289
+ </td>
290
+ ) : null}
291
+ </tr>
292
+ ))
293
+ )}
294
+ </tbody>
295
+ </table>
296
+ </div>
297
+
298
+ {!isLoading && paginated.length > 0 && (
299
+ <div
300
+ className={cn(
301
+ "flex items-center gap-4 flex-wrap border-t border-border",
302
+ footerPad,
303
+ footerSummary === "count" && totalPages <= 1
304
+ ? "justify-start"
305
+ : "justify-between"
306
+ )}
307
+ >
308
+ {footerSummary === "count" ? (
309
+ <span className="text-[12px] text-muted-foreground tabular-nums">
310
+ <span className="font-medium text-foreground">{total}</span>{" "}
311
+ {total === 1
312
+ ? footerCountLabels.singular
313
+ : footerCountLabels.plural}
314
+ </span>
315
+ ) : (
316
+ <span className="text-[12px] text-muted-foreground tabular-nums">
317
+ Showing{" "}
318
+ <span className="text-foreground font-medium">
319
+ {Math.min((page - 1) * pageSize + 1, total)}–
320
+ {Math.min(page * pageSize, total)}
321
+ </span>{" "}
322
+ of{" "}
323
+ <span className="text-foreground font-medium">
324
+ {total.toLocaleString()}
325
+ </span>{" "}
326
+ {total !== 1 ? "results" : "result"}
327
+ </span>
328
+ )}
329
+
330
+ {(footerSummary === "range" || footerSummary === "count") &&
331
+ totalPages > 1 && (
332
+ <div className="flex items-center gap-1">
333
+ <button
334
+ onClick={() => setPage(Math.max(1, page - 1))}
335
+ disabled={page === 1}
336
+ className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
337
+ >
338
+ <ChevronLeft className="w-3.5 h-3.5" />
339
+ </button>
340
+
341
+ {getPageRange(page, totalPages).map((p, idx) =>
342
+ p === "…" ? (
343
+ <span
344
+ key={`ellipsis-${idx}`}
345
+ className="w-7 h-7 flex items-center justify-center text-[12px] text-muted-foreground select-none"
346
+ >
347
+ …
348
+ </span>
349
+ ) : (
350
+ <button
351
+ key={p}
352
+ onClick={() => setPage(p as number)}
353
+ className={cn(
354
+ "w-7 h-7 rounded-md text-[12px] font-medium transition-colors tabular-nums flex items-center justify-center",
355
+ page === p
356
+ ? "bg-primary text-primary-foreground shadow-sm"
357
+ : "text-muted-foreground hover:bg-muted hover:text-foreground"
358
+ )}
359
+ >
360
+ {p}
361
+ </button>
362
+ )
363
+ )}
364
+
365
+ <button
366
+ onClick={() => setPage(Math.min(totalPages, page + 1))}
367
+ disabled={page === totalPages}
368
+ className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
369
+ >
370
+ <ChevronRight className="w-3.5 h-3.5" />
371
+ </button>
372
+ </div>
373
+ )}
374
+ </div>
375
+ )}
376
+ </div>
377
+ );
378
+ }
@@ -0,0 +1,317 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { ChevronLeft, ChevronRight, ChevronDown, CalendarDays } from "lucide-react";
6
+ import { AnimatePresence, motion } from "framer-motion";
7
+ import { cn } from "./utils";
8
+
9
+ /* ─── Constants ─────────────────────────────────────────────────────────── */
10
+ const MONTHS = ["January","February","March","April","May","June",
11
+ "July","August","September","October","November","December"];
12
+ const DAYS = ["Su","Mo","Tu","We","Th","Fr","Sa"];
13
+ const PRIMARY = "#0061E3";
14
+ const PANEL_W = 296;
15
+
16
+ /* ─── Helpers ────────────────────────────────────────────────────────────── */
17
+ function daysInMonth(y: number, m: number) { return new Date(y, m + 1, 0).getDate(); }
18
+ function firstDayOf(y: number, m: number) { return new Date(y, m, 1).getDay(); }
19
+
20
+ function parseYMD(s: string) {
21
+ if (!s) return null;
22
+ const [y, m, d] = s.split("-").map(Number);
23
+ if (!y || !m || !d) return null;
24
+ return { y, m: m - 1, d };
25
+ }
26
+ function toYMD(y: number, m: number, d: number) {
27
+ return `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
28
+ }
29
+ function displayDate(ymd: string) {
30
+ const p = parseYMD(ymd);
31
+ if (!p) return "";
32
+ return `${String(p.d).padStart(2, "0")} ${MONTHS[p.m].slice(0, 3)} ${p.y}`;
33
+ }
34
+
35
+ /* ─── Props ─────────────────────────────────────────────────────────────── */
36
+ interface DatePickerProps {
37
+ value: string;
38
+ onChange: (v: string) => void;
39
+ placeholder?: string;
40
+ className?: string;
41
+ min?: string;
42
+ label?: string;
43
+ }
44
+
45
+ /* ─── DatePicker ─────────────────────────────────────────────────────────── */
46
+ export function DatePicker({ value, onChange, placeholder = "Select date", className, min, label }: DatePickerProps) {
47
+ const today = new Date();
48
+ const parsed = parseYMD(value);
49
+
50
+ const [open, setOpen] = useState(false);
51
+ const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
52
+ const [viewYear, setViewYear] = useState(parsed?.y ?? today.getFullYear());
53
+ const [viewMonth, setViewMonth] = useState(parsed?.m ?? today.getMonth());
54
+ const [yearMenu, setYearMenu] = useState(false);
55
+ const [monthMenu, setMonthMenu] = useState(false);
56
+ const [mounted, setMounted] = useState(false);
57
+
58
+ const triggerRef = useRef<HTMLButtonElement>(null);
59
+ const panelRef = useRef<HTMLDivElement>(null);
60
+
61
+ useEffect(() => { setMounted(true); }, []);
62
+
63
+ /* Sync view when value changes */
64
+ useEffect(() => {
65
+ if (parsed) { setViewYear(parsed.y); setViewMonth(parsed.m); }
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [value]);
68
+
69
+ /* Compute fixed position from trigger rect */
70
+ function openPanel() {
71
+ if (!triggerRef.current) return;
72
+ const trigger = triggerRef.current;
73
+ const vw = window.innerWidth;
74
+ const vh = window.innerHeight;
75
+ const PANEL_H = 340; // approx height
76
+
77
+ // Scroll trigger into view so panel can appear next to it (avoids panel far from input in scrollable forms)
78
+ trigger.scrollIntoView({ block: "center", behavior: "auto" });
79
+
80
+ requestAnimationFrame(() => {
81
+ if (!triggerRef.current) return;
82
+ const rect = triggerRef.current.getBoundingClientRect();
83
+
84
+ // Horizontal: align left edge, clamp so it doesn't go off screen
85
+ let left = rect.left;
86
+ if (left + PANEL_W > vw - 8) left = vw - PANEL_W - 8;
87
+
88
+ // Vertical: prefer below trigger; if not enough room open above
89
+ let top = rect.bottom + 6;
90
+ if (top + PANEL_H > vh - 8) top = rect.top - PANEL_H - 6;
91
+
92
+ setPanelPos({ top, left });
93
+ setOpen(true);
94
+ });
95
+ }
96
+
97
+ /* Close on outside click */
98
+ useEffect(() => {
99
+ if (!open) return;
100
+ function handler(e: MouseEvent) {
101
+ const inTrigger = triggerRef.current?.contains(e.target as Node);
102
+ const inPanel = panelRef.current?.contains(e.target as Node);
103
+ if (!inTrigger && !inPanel) {
104
+ setOpen(false); setYearMenu(false); setMonthMenu(false);
105
+ }
106
+ }
107
+ document.addEventListener("mousedown", handler);
108
+ return () => document.removeEventListener("mousedown", handler);
109
+ }, [open]);
110
+
111
+ /* Navigation */
112
+ function prevMonth() {
113
+ if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); }
114
+ else setViewMonth(m => m - 1);
115
+ }
116
+ function nextMonth() {
117
+ if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); }
118
+ else setViewMonth(m => m + 1);
119
+ }
120
+
121
+ /* Day grid */
122
+ const totalDays = daysInMonth(viewYear, viewMonth);
123
+ const firstDay = firstDayOf(viewYear, viewMonth);
124
+ const prevTotal = daysInMonth(viewYear, viewMonth === 0 ? 11 : viewMonth - 1);
125
+ const minParsed = parseYMD(min ?? "");
126
+
127
+ 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);
130
+ }
131
+
132
+ type Cell = { d: number; m: number; y: number; current: boolean };
133
+ const cells: Cell[] = [];
134
+ for (let i = 0; i < firstDay; i++) {
135
+ const d = prevTotal - firstDay + 1 + i;
136
+ const m = viewMonth === 0 ? 11 : viewMonth - 1;
137
+ const y = viewMonth === 0 ? viewYear - 1 : viewYear;
138
+ cells.push({ d, m, y, current: false });
139
+ }
140
+ for (let d = 1; d <= totalDays; d++) cells.push({ d, m: viewMonth, y: viewYear, current: true });
141
+ const remaining = 42 - cells.length;
142
+ for (let d = 1; d <= remaining; d++) {
143
+ const m = viewMonth === 11 ? 0 : viewMonth + 1;
144
+ const y = viewMonth === 11 ? viewYear + 1 : viewYear;
145
+ cells.push({ d, m, y, current: false });
146
+ }
147
+
148
+ const years = Array.from({ length: 15 }, (_, i) => today.getFullYear() - 2 + i);
149
+
150
+ function selectDay(cell: Cell) {
151
+ if (!cell.current) { setViewYear(cell.y); setViewMonth(cell.m); }
152
+ if (cell.current && isDisabled(cell.y, cell.m, cell.d)) return;
153
+ onChange(toYMD(cell.y, cell.m, cell.d));
154
+ setOpen(false);
155
+ }
156
+
157
+ const isToday = (c: Cell) => c.d === today.getDate() && c.m === today.getMonth() && c.y === today.getFullYear();
158
+ const isSelected = (c: Cell) => !!parsed && c.d === parsed.d && c.m === parsed.m && c.y === parsed.y;
159
+
160
+ /* ── Calendar panel (portalled) ── */
161
+ const panel = (
162
+ <AnimatePresence>
163
+ {open && (
164
+ <motion.div
165
+ ref={panelRef}
166
+ initial={{ opacity: 0, scale: 0.97, y: -6 }}
167
+ animate={{ opacity: 1, scale: 1, y: 0 }}
168
+ exit={{ opacity: 0, scale: 0.97, y: -6 }}
169
+ transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
170
+ className="isolate rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg select-none dark:shadow-black/40"
171
+ style={{
172
+ position: "fixed",
173
+ top: panelPos.top,
174
+ left: panelPos.left,
175
+ width: PANEL_W,
176
+ zIndex: 20000,
177
+ backgroundColor: "var(--popover)",
178
+ boxShadow: "0 16px 40px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.07)",
179
+ }}
180
+ >
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>
287
+ </motion.div>
288
+ )}
289
+ </AnimatePresence>
290
+ );
291
+
292
+ return (
293
+ <div className={cn("relative", className)}>
294
+ {label && <p className="mb-2 text-sm font-medium text-foreground">{label}</p>}
295
+
296
+ {/* Trigger */}
297
+ <button
298
+ ref={triggerRef}
299
+ type="button"
300
+ onClick={() => open ? setOpen(false) : openPanel()}
301
+ className={cn(
302
+ "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
+ open ? "border-ring ring-2 ring-ring/20" : "hover:border-muted-foreground/45",
304
+ )}
305
+ >
306
+ <CalendarDays className="size-[1.125rem] shrink-0 text-muted-foreground" />
307
+ <span className={cn("flex-1", value ? "text-foreground" : "text-muted-foreground")}>
308
+ {value ? displayDate(value) : placeholder}
309
+ </span>
310
+ <ChevronDown className={cn("size-[1.125rem] shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
311
+ </button>
312
+
313
+ {/* Portal */}
314
+ {mounted && createPortal(panel, document.body)}
315
+ </div>
316
+ );
317
+ }