@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.
- package/package.json +72 -0
- package/src/accordion.tsx +68 -0
- package/src/alert.tsx +107 -0
- package/src/avatar-group.tsx +96 -0
- package/src/avatar-tag.tsx +136 -0
- package/src/avatar.tsx +39 -0
- package/src/badge.tsx +98 -0
- package/src/blanket.tsx +61 -0
- package/src/breadcrumb.tsx +119 -0
- package/src/button-group.tsx +218 -0
- package/src/button.tsx +83 -0
- package/src/calendar.tsx +227 -0
- package/src/callout.tsx +68 -0
- package/src/card.tsx +103 -0
- package/src/chart-templates.tsx +587 -0
- package/src/chart.tsx +379 -0
- package/src/checkbox-select.tsx +239 -0
- package/src/checkbox.tsx +54 -0
- package/src/code.tsx +154 -0
- package/src/command.tsx +77 -0
- package/src/country-select.tsx +242 -0
- package/src/currency-amount-input.tsx +72 -0
- package/src/data-table.tsx +378 -0
- package/src/date-picker.tsx +317 -0
- package/src/dialog.tsx +81 -0
- package/src/drawer.tsx +91 -0
- package/src/dropdown-menu.tsx +174 -0
- package/src/empty-state.tsx +32 -0
- package/src/field.tsx +243 -0
- package/src/flag.tsx +265 -0
- package/src/form.tsx +168 -0
- package/src/grid-flex.tsx +241 -0
- package/src/heading.tsx +202 -0
- package/src/icon-button.tsx +93 -0
- package/src/index.ts +332 -0
- package/src/inline-dialog.tsx +153 -0
- package/src/inline-edit.tsx +212 -0
- package/src/input-group.tsx +151 -0
- package/src/input.tsx +28 -0
- package/src/label.tsx +21 -0
- package/src/layout.tsx +119 -0
- package/src/link.tsx +80 -0
- package/src/lozenge.tsx +61 -0
- package/src/menu.tsx +146 -0
- package/src/otp-input.tsx +117 -0
- package/src/page-header.tsx +28 -0
- package/src/pagination.tsx +185 -0
- package/src/password-input.tsx +34 -0
- package/src/popover.tsx +31 -0
- package/src/progress-indicator.tsx +94 -0
- package/src/progress.tsx +95 -0
- package/src/radio-group.tsx +46 -0
- package/src/responsive.tsx +276 -0
- package/src/scroll-area.tsx +39 -0
- package/src/section-message.tsx +119 -0
- package/src/select.tsx +144 -0
- package/src/separator.tsx +26 -0
- package/src/side-nav.tsx +264 -0
- package/src/skeleton.tsx +74 -0
- package/src/slider.tsx +25 -0
- package/src/sonner.tsx +32 -0
- package/src/spinner.tsx +54 -0
- package/src/spotlight.tsx +141 -0
- package/src/status-badge.tsx +86 -0
- package/src/switch.tsx +70 -0
- package/src/tabs.tsx +57 -0
- package/src/tag.tsx +52 -0
- package/src/textarea.tsx +25 -0
- package/src/time-picker.tsx +443 -0
- package/src/tooltip.tsx +29 -0
- package/src/utils.ts +6 -0
- 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
|
+
}
|