@payglocal_ui/flux-ui 0.2.6 → 0.3.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.
@@ -0,0 +1,262 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "./button";
5
+ import { Calendar } from "./calendar";
6
+ import { FilterChip, FilterChipActions, useFilterChipState } from "./filter-chips";
7
+ import type { FilterChipControl } from "./filter-chips";
8
+ import { formatDateOnly, parseApiDate } from "./format-datetime";
9
+ import { cn } from "./utils";
10
+
11
+ export type DatePickMode = "single" | "range";
12
+
13
+ /** What the calendar hands back while the user is picking. */
14
+ export type CalendarRange = { from: Date | undefined; to?: Date | undefined };
15
+
16
+ /**
17
+ * A named span offered above the calendar — "Today", "Last 30 Days".
18
+ *
19
+ * `resolve` runs when the preset is chosen, not when it is declared, so "last
20
+ * 7 days" is counted from the day the user picks it rather than from whenever
21
+ * the options array happened to be built.
22
+ */
23
+ export interface CalendarDatePreset {
24
+ value: string;
25
+ label: string;
26
+ resolve: () => { from: string; to: string };
27
+ }
28
+
29
+ /**
30
+ * The applied value: a span, plus which preset produced it.
31
+ *
32
+ * `preset` is `""` when the dates were picked by hand, and `to` equals `from`
33
+ * for a single day — so a caller that only wants a window can read `from`/`to`
34
+ * and ignore the rest.
35
+ */
36
+ export interface CalendarDateValue {
37
+ preset: string;
38
+ /** YYYY-MM-DD */
39
+ from: string;
40
+ /** YYYY-MM-DD */
41
+ to: string;
42
+ }
43
+
44
+ export interface CalendarDateFilterChipProps extends FilterChipControl {
45
+ chipKey?: string;
46
+ label?: string;
47
+ value?: CalendarDateValue;
48
+ onChange: (next: CalendarDateValue | undefined) => void;
49
+ /** Named spans above the calendar. Omit for a calendar-only chip. */
50
+ presets?: readonly CalendarDatePreset[];
51
+ /** Offer "Single date" alongside "Date range". Default true. */
52
+ allowSingle?: boolean;
53
+ /** Months shown side by side in range mode. Default 2. */
54
+ numberOfMonths?: number;
55
+ align?: "start" | "center" | "end";
56
+ }
57
+
58
+ const PICK_MODES: { value: DatePickMode; label: string }[] = [
59
+ { value: "single", label: "Single date" },
60
+ { value: "range", label: "Date range" },
61
+ ];
62
+
63
+ const toKey = (d: Date): string => {
64
+ const month = String(d.getMonth() + 1).padStart(2, "0");
65
+ const day = String(d.getDate()).padStart(2, "0");
66
+ return `${d.getFullYear()}-${month}-${day}`;
67
+ };
68
+
69
+ const fromKey = (key: string): Date | undefined => parseApiDate(key) ?? undefined;
70
+
71
+ /**
72
+ * A date filter over a real calendar, with optional named spans.
73
+ *
74
+ * Distinct from {@link DateRangeFilterChip}, which is two typed date fields.
75
+ * This one is for a filter people reach for by *looking* — "the week of the
76
+ * 14th", "that Tuesday" — where a pair of text inputs makes you count days in
77
+ * your head. Both exist because both are right somewhere, and picking between
78
+ * them is a call about the filter, not about the toolbar.
79
+ *
80
+ * Five features in pg-dashboard-v2 had built this chip separately, each with
81
+ * its own preset list and its own value shape. They are the same control.
82
+ */
83
+ export function CalendarDateFilterChip({
84
+ chipKey,
85
+ label = "Date",
86
+ value,
87
+ onChange,
88
+ presets,
89
+ allowSingle = true,
90
+ numberOfMonths = 2,
91
+ align = "start",
92
+ open,
93
+ onOpenChange,
94
+ }: CalendarDateFilterChipProps) {
95
+ const key = chipKey ?? label;
96
+ const chip = useFilterChipState(key, { open, onOpenChange });
97
+
98
+ const [mode, setMode] = useState<DatePickMode>("single");
99
+ const [singleDate, setSingleDate] = useState<Date | undefined>(undefined);
100
+ const [range, setRange] = useState<CalendarRange | undefined>(undefined);
101
+ // A preset and a hand-picked span are the same filter reached two ways, so
102
+ // choosing one clears the other rather than leaving both staged.
103
+ const [presetDraft, setPresetDraft] = useState("");
104
+
105
+ const activePreset = presets?.find((p) => p.value === value?.preset);
106
+ const isActive = !!value?.from;
107
+
108
+ const chipLabel = !isActive
109
+ ? label
110
+ : activePreset
111
+ ? `${label}: ${activePreset.label}`
112
+ : value!.to && value!.to !== value!.from
113
+ ? `${label}: ${formatDateOnly(fromKey(value!.from)!)} – ${formatDateOnly(fromKey(value!.to)!)}`
114
+ : `${label}: ${formatDateOnly(fromKey(value!.from)!)}`;
115
+
116
+ /** Reseeds the working selection from what is applied, on every open. */
117
+ const reseed = () => {
118
+ setPresetDraft(value?.preset ?? "");
119
+ const from = value?.from ? fromKey(value.from) : undefined;
120
+ const to = value?.to ? fromKey(value.to) : undefined;
121
+ const isSpan = !!value?.to && value.to !== value.from;
122
+ setMode(isSpan || !allowSingle ? "range" : "single");
123
+ setSingleDate(isSpan ? undefined : from);
124
+ setRange(isSpan ? { from, to } : undefined);
125
+ };
126
+
127
+ const clear = () => {
128
+ onChange(undefined);
129
+ setPresetDraft("");
130
+ setSingleDate(undefined);
131
+ setRange(undefined);
132
+ setMode(allowSingle ? "single" : "range");
133
+ };
134
+
135
+ const apply = () => {
136
+ if (presetDraft) {
137
+ const chosen = presets?.find((p) => p.value === presetDraft);
138
+ if (chosen) {
139
+ const span = chosen.resolve();
140
+ onChange({ preset: chosen.value, ...span });
141
+ }
142
+ } else if (mode === "single" && singleDate) {
143
+ const k = toKey(singleDate);
144
+ onChange({ preset: "", from: k, to: k });
145
+ } else if (mode === "range" && range?.from) {
146
+ const to = range.to ?? range.from;
147
+ onChange({ preset: "", from: toKey(range.from), to: toKey(to) });
148
+ } else {
149
+ onChange(undefined);
150
+ }
151
+ chip.onOpenChange(false);
152
+ };
153
+
154
+ const hasDraft = !!presetDraft || !!singleDate || !!range?.from;
155
+
156
+ return (
157
+ <FilterChip
158
+ chipKey={key}
159
+ label={chipLabel}
160
+ active={isActive}
161
+ align={align}
162
+ open={chip.open}
163
+ onOpenChange={chip.onOpenChange}
164
+ onOpen={reseed}
165
+ onClear={clear}
166
+ >
167
+ <div className="w-auto p-3">
168
+ {presets?.length ? (
169
+ <div className="mb-3 flex flex-wrap gap-1.5">
170
+ {presets.map((p) => (
171
+ <Button
172
+ key={p.value}
173
+ type="button"
174
+ variant="ghost"
175
+ size="sm"
176
+ onClick={() => {
177
+ const next = presetDraft === p.value ? "" : p.value;
178
+ setPresetDraft(next);
179
+ // Picking a named span drops whatever was on the calendar,
180
+ // so the panel never shows two answers at once.
181
+ if (next) {
182
+ setSingleDate(undefined);
183
+ setRange(undefined);
184
+ }
185
+ }}
186
+ className={cn(
187
+ "h-auto min-h-0 rounded-full border px-2.5 py-1 text-[11.5px] font-normal",
188
+ presetDraft === p.value
189
+ ? "border-primary bg-primary/10 font-medium text-primary"
190
+ : "border-border text-muted-foreground hover:text-foreground"
191
+ )}
192
+ >
193
+ {p.label}
194
+ </Button>
195
+ ))}
196
+ </div>
197
+ ) : null}
198
+
199
+ {allowSingle ? (
200
+ <div className="mb-3 flex items-center gap-1 rounded-lg border border-border bg-muted/50 p-1">
201
+ {PICK_MODES.map((m) => (
202
+ <Button
203
+ key={m.value}
204
+ type="button"
205
+ variant="ghost"
206
+ size="sm"
207
+ onClick={() => setMode(m.value)}
208
+ className={cn(
209
+ "h-auto min-h-0 flex-1 whitespace-nowrap rounded-md px-2.5 py-1.5 text-xs font-medium",
210
+ mode === m.value
211
+ ? "bg-card text-foreground shadow-sm"
212
+ : "text-muted-foreground hover:text-foreground"
213
+ )}
214
+ >
215
+ {m.label}
216
+ </Button>
217
+ ))}
218
+ </div>
219
+ ) : null}
220
+
221
+ {/* `bg-transparent p-0`: Calendar paints `bg-background` on itself and
222
+ only drops it via a `[[data-slot=popover-content]_&]` selector that
223
+ PopoverContent never sets. Left alone it renders the page's
224
+ off-white as a solid block inside the white popover, with its own
225
+ `p-3` on top of the popover's — a greyed, inset panel that reads as
226
+ disabled. */}
227
+ {mode === "single" && allowSingle ? (
228
+ <Calendar
229
+ mode="single"
230
+ selected={singleDate}
231
+ onSelect={(d) => {
232
+ setSingleDate(d);
233
+ setPresetDraft("");
234
+ }}
235
+ className="bg-transparent p-0"
236
+ />
237
+ ) : (
238
+ <Calendar
239
+ mode="range"
240
+ selected={range}
241
+ onSelect={(r) => {
242
+ setRange(r);
243
+ setPresetDraft("");
244
+ }}
245
+ numberOfMonths={numberOfMonths}
246
+ className="bg-transparent p-0"
247
+ />
248
+ )}
249
+ </div>
250
+
251
+ <FilterChipActions
252
+ onClear={() => {
253
+ clear();
254
+ chip.onOpenChange(false);
255
+ }}
256
+ clearDisabled={!hasDraft && !isActive}
257
+ applyDisabled={!hasDraft}
258
+ onApply={apply}
259
+ />
260
+ </FilterChip>
261
+ );
262
+ }