@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.
@@ -0,0 +1,237 @@
1
+ import { describe, expect, it, beforeAll } from "vitest";
2
+ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
3
+ import { useState } from "react";
4
+ import {
5
+ DateRangeFilterChip,
6
+ FilterToolbar,
7
+ SelectFilterChip,
8
+ AddFilterMenu,
9
+ FilterChipGroup,
10
+ useFilterChipState,
11
+ } from "../filter-chips";
12
+
13
+ beforeAll(() => {
14
+ if (!(globalThis as any).PointerEvent) {
15
+ class PE extends MouseEvent {
16
+ pointerType: string;
17
+ constructor(type: string, props: any = {}) {
18
+ super(type, props);
19
+ this.pointerType = props.pointerType ?? "mouse";
20
+ }
21
+ }
22
+ (globalThis as any).PointerEvent = PE;
23
+ }
24
+ // Radix measures the trigger to position the popover.
25
+ if (!(globalThis as any).ResizeObserver) {
26
+ (globalThis as any).ResizeObserver = class {
27
+ observe() {}
28
+ unobserve() {}
29
+ disconnect() {}
30
+ };
31
+ }
32
+ });
33
+
34
+ // The real toolbar has ~10 options per category, which crosses
35
+ // SelectFilterChip's searchThreshold and renders a search box in the panel.
36
+ const MANY = Array.from({ length: 10 }, (_, i) => ({
37
+ value: `v${i}`,
38
+ label: `Option ${i}`,
39
+ }));
40
+
41
+ /** Mirrors TxnFilters: search, Date chip, two category chips, an add menu. */
42
+ function Toolbar() {
43
+ const [date, setDate] = useState({ from: "", to: "" });
44
+ const [type, setType] = useState<string[]>([]);
45
+ const [status, setStatus] = useState<string[]>([]);
46
+ return (
47
+ <FilterToolbar
48
+ search={<input aria-label="Search" />}
49
+ chips={
50
+ <>
51
+ <DateRangeFilterChip value={date} onChange={setDate} />
52
+ <SelectFilterChip label="Transaction Type" options={MANY} selected={type} onChange={setType} />
53
+ <SelectFilterChip label="Transaction Status" options={MANY} selected={status} onChange={setStatus} />
54
+ <AddFilterMenu filters={[{ key: "x", label: "Country", options: MANY }]} onAddFilter={() => {}} />
55
+ </>
56
+ }
57
+ />
58
+ );
59
+ }
60
+
61
+ function press(el: Element) {
62
+ fireEvent.pointerDown(el, { pointerType: "mouse", button: 0, bubbles: true });
63
+ fireEvent.mouseDown(el, { button: 0, bubbles: true });
64
+ fireEvent.pointerUp(el, { pointerType: "mouse", button: 0, bubbles: true });
65
+ fireEvent.mouseUp(el, { button: 0, bubbles: true });
66
+ fireEvent.click(el, { button: 0, bubbles: true });
67
+ }
68
+
69
+ const which = () =>
70
+ screen.queryAllByPlaceholderText(/^Search /).map((i) => i.getAttribute("placeholder"));
71
+
72
+ describe("switching straight between two open chips", () => {
73
+ it("closes the first and leaves the second open", async () => {
74
+ render(<Toolbar />);
75
+
76
+ press(screen.getByRole("button", { name: /Transaction Type/ }));
77
+ await waitFor(() => expect(which()).toContain("Search transaction type"));
78
+ console.log("after opening Type :", which());
79
+
80
+ press(screen.getByRole("button", { name: /Transaction Status/ }));
81
+ await new Promise((r) => setTimeout(r, 300));
82
+ console.log("after clicking Status:", which());
83
+
84
+ expect(which()).toEqual(["Search transaction status"]);
85
+ });
86
+ });
87
+
88
+ describe("the handoff", () => {
89
+ it("never has two chip popovers open at once", async () => {
90
+ render(<Toolbar />);
91
+
92
+ press(screen.getByRole("button", { name: /Transaction Type/ }));
93
+ await waitFor(() => expect(which()).toContain("Search transaction type"));
94
+
95
+ press(screen.getByRole("button", { name: /Transaction Status/ }));
96
+
97
+ // Sample across the handoff frame; at no point should both be mounted.
98
+ for (let i = 0; i < 20; i++) {
99
+ expect(which().length).toBeLessThanOrEqual(1);
100
+ await new Promise((r) => setTimeout(r, 20));
101
+ }
102
+ expect(which()).toEqual(["Search transaction status"]);
103
+ });
104
+
105
+ it("still closes on a second click of the same chip", async () => {
106
+ render(<Toolbar />);
107
+ const type = screen.getByRole("button", { name: /Transaction Type/ });
108
+
109
+ press(type);
110
+ await waitFor(() => expect(which().length).toBe(1));
111
+ press(type);
112
+ await waitFor(() => expect(which().length).toBe(0));
113
+ });
114
+ });
115
+
116
+ /**
117
+ * The regression this file exists for.
118
+ *
119
+ * Radix restores focus to a popover's trigger on close, skipping it only when
120
+ * the popover was dismissed by an *outside interaction*. A handoff is neither:
121
+ * the group closes the outgoing chip programmatically, so Radix restores focus
122
+ * to its trigger — which lands outside the chip now opening and makes Radix
123
+ * dismiss that one. The chip appears and vanishes.
124
+ */
125
+ describe("handoff focus suppression", () => {
126
+ it("suppresses the outgoing chip's focus restore exactly once", async () => {
127
+ const seen: Array<{ key: string; prevented: boolean }> = [];
128
+
129
+ function Probe() {
130
+ const a = useFilterChipState("a");
131
+ const b = useFilterChipState("b");
132
+ return (
133
+ <>
134
+ <button onClick={() => a.onOpenChange(true)}>open a</button>
135
+ <button onClick={() => b.onOpenChange(true)}>open b</button>
136
+ <button
137
+ onClick={() => {
138
+ for (const [key, chip] of [
139
+ ["a", a],
140
+ ["b", b],
141
+ ] as const) {
142
+ const e = new Event("x", { cancelable: true });
143
+ chip.onCloseAutoFocus(e);
144
+ seen.push({ key, prevented: e.defaultPrevented });
145
+ }
146
+ }}
147
+ >
148
+ close-auto-focus
149
+ </button>
150
+ </>
151
+ );
152
+ }
153
+
154
+ render(
155
+ <FilterChipGroup>
156
+ <Probe />
157
+ </FilterChipGroup>
158
+ );
159
+
160
+ fireEvent.click(screen.getByText("open a"));
161
+ fireEvent.click(screen.getByText("open b")); // handoff: a -> b
162
+ fireEvent.click(screen.getByText("close-auto-focus"));
163
+
164
+ // Only the chip handed off from suppresses its focus restore.
165
+ expect(seen).toEqual([
166
+ { key: "a", prevented: true },
167
+ { key: "b", prevented: false },
168
+ ]);
169
+
170
+ // And only once — a later close must restore focus normally.
171
+ seen.length = 0;
172
+ fireEvent.click(screen.getByText("close-auto-focus"));
173
+ expect(seen).toEqual([
174
+ { key: "a", prevented: false },
175
+ { key: "b", prevented: false },
176
+ ]);
177
+ });
178
+ });
179
+
180
+ /**
181
+ * Both footer buttons commit and close. Clear used to reset only the draft and
182
+ * leave the panel open, so the chip still read "Type 1" while the list in front
183
+ * of you showed nothing ticked — and closing the panel kept the old filter.
184
+ */
185
+ describe("the Apply / Clear footer", () => {
186
+ function ApplyClearToolbar() {
187
+ const [type, setType] = useState<string[]>([]);
188
+ return (
189
+ <FilterToolbar
190
+ chips={<SelectFilterChip label="Type" options={MANY} selected={type} onChange={setType} />}
191
+ />
192
+ );
193
+ }
194
+
195
+ const openTypeChip = async () => {
196
+ fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
197
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeTruthy());
198
+ };
199
+
200
+ it("Apply commits the draft and closes", async () => {
201
+ render(<ApplyClearToolbar />);
202
+ await openTypeChip();
203
+ fireEvent.click(screen.getByText("Option 1"));
204
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
205
+
206
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeFalsy());
207
+ expect(screen.getByRole("button", { name: /^Type/ }).textContent).toContain("1");
208
+ });
209
+
210
+ it("Clear drops the applied filter and closes", async () => {
211
+ render(<ApplyClearToolbar />);
212
+ await openTypeChip();
213
+ fireEvent.click(screen.getByText("Option 1"));
214
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
215
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeFalsy());
216
+
217
+ await openTypeChip();
218
+ fireEvent.click(screen.getByRole("button", { name: "Clear" }));
219
+
220
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeFalsy());
221
+ expect(screen.getByRole("button", { name: /^Type/ }).textContent).not.toContain("1");
222
+ });
223
+
224
+ it("Clear stays live over an applied filter whose draft has been emptied", async () => {
225
+ render(<ApplyClearToolbar />);
226
+ await openTypeChip();
227
+ fireEvent.click(screen.getByText("Option 1"));
228
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
229
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeFalsy());
230
+
231
+ await openTypeChip();
232
+ // Untick it again: the draft is empty but there is still a filter applied.
233
+ fireEvent.click(screen.getByText("Option 1"));
234
+ const clear = screen.getByRole("button", { name: "Clear" }) as HTMLButtonElement;
235
+ expect(clear.disabled).toBe(false);
236
+ });
237
+ });
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it, beforeAll, vi } from "vitest";
2
+ import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
3
+ import { useState } from "react";
4
+ import { Drawer, DrawerContent } from "../drawer";
5
+ import { DatePicker } from "../date-picker";
6
+ import { TimePicker } from "../time-picker";
7
+
8
+ /**
9
+ * Both pickers portal their panel to `document.body`. A modal Radix Dialog sets
10
+ * `pointer-events: none` on `<body>` for as long as it is open, so a panel that
11
+ * does not re-enable pointer events for itself renders perfectly and answers no
12
+ * click — which is exactly how the MCA "Start a new batch" drawer's date and
13
+ * time fields failed.
14
+ */
15
+
16
+ beforeAll(() => {
17
+ if (!(globalThis as any).PointerEvent) {
18
+ class PE extends MouseEvent {
19
+ pointerType: string;
20
+ constructor(type: string, props: any = {}) {
21
+ super(type, props);
22
+ this.pointerType = props.pointerType ?? "mouse";
23
+ }
24
+ }
25
+ (globalThis as any).PointerEvent = PE;
26
+ }
27
+ if (!(globalThis as any).ResizeObserver) {
28
+ (globalThis as any).ResizeObserver = class {
29
+ observe() {}
30
+ unobserve() {}
31
+ disconnect() {}
32
+ };
33
+ }
34
+ // jsdom implements neither, and both pickers call them when opening.
35
+ Element.prototype.scrollIntoView = vi.fn();
36
+ Element.prototype.scrollTo = vi.fn();
37
+ });
38
+
39
+ function DateHarness({
40
+ onChange,
41
+ showTime = false,
42
+ initial = "2026-09-10",
43
+ }: {
44
+ onChange: (v: string) => void;
45
+ showTime?: boolean;
46
+ initial?: string;
47
+ }) {
48
+ const [value, setValue] = useState(initial);
49
+ return (
50
+ <Drawer open onOpenChange={() => {}} side="right">
51
+ <DrawerContent>
52
+ <DatePicker
53
+ value={value}
54
+ showTime={showTime}
55
+ onChange={(v) => {
56
+ setValue(v);
57
+ onChange(v);
58
+ }}
59
+ />
60
+ </DrawerContent>
61
+ </Drawer>
62
+ );
63
+ }
64
+
65
+ function TimeHarness({ onChange }: { onChange: (v: string) => void }) {
66
+ const [value, setValue] = useState("09:30");
67
+ return (
68
+ <Drawer open onOpenChange={() => {}} side="right">
69
+ <DrawerContent>
70
+ <TimePicker
71
+ value={value}
72
+ onValueChange={(v) => {
73
+ setValue(v);
74
+ onChange(v);
75
+ }}
76
+ />
77
+ </DrawerContent>
78
+ </Drawer>
79
+ );
80
+ }
81
+
82
+ describe("pickers inside a modal drawer", () => {
83
+ it("DatePicker selects a day while the drawer holds body pointer events", async () => {
84
+ const onChange = vi.fn();
85
+ render(<DateHarness onChange={onChange} />);
86
+
87
+ expect(document.body.style.pointerEvents).toBe("none");
88
+
89
+ fireEvent.click(screen.getByText("10 Sep 2026"));
90
+
91
+ const day = await screen.findByRole("button", { name: "17" });
92
+ // The panel must opt back into pointer events, or this click never lands
93
+ // on the day in a real browser.
94
+ const panel = day.closest("[style*='position: fixed']") as HTMLElement;
95
+ expect(panel.style.pointerEvents).toBe("auto");
96
+
97
+ fireEvent.click(day);
98
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("2026-09-17"));
99
+ });
100
+
101
+ it("TimePicker selects an hour while the drawer holds body pointer events", async () => {
102
+ const onChange = vi.fn();
103
+ render(<TimeHarness onChange={onChange} />);
104
+
105
+ expect(document.body.style.pointerEvents).toBe("none");
106
+
107
+ fireEvent.click(screen.getByText("09:30 AM"));
108
+
109
+ const panel = (await screen.findByText("Hr")).closest(
110
+ "[style*='position: fixed']"
111
+ ) as HTMLElement;
112
+ expect(panel.style.pointerEvents).toBe("auto");
113
+
114
+ // "11" appears once per column pair; the hour column is the first.
115
+ const eleven = screen.getAllByText("11")[0];
116
+ fireEvent.click(eleven);
117
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("11:30"));
118
+ });
119
+
120
+ it("DatePicker's time columns are clickable inside the drawer too", async () => {
121
+ const onChange = vi.fn();
122
+ render(
123
+ <DateHarness onChange={onChange} showTime initial="2026-09-10 14:30" />
124
+ );
125
+
126
+ expect(document.body.style.pointerEvents).toBe("none");
127
+
128
+ fireEvent.click(screen.getByText("10 Sep 2026, 02:30 PM"));
129
+
130
+ // The whole panel, time columns and footer included, opts back in.
131
+ const panel = (await screen.findByText("OK")).closest(
132
+ "[style*='position: fixed']"
133
+ ) as HTMLElement;
134
+ expect(panel.style.pointerEvents).toBe("auto");
135
+
136
+ const minutes = screen.getByText("Min").parentElement as HTMLElement;
137
+ fireEvent.click(within(minutes).getByRole("button", { name: "45" }));
138
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("2026-09-10 14:45"));
139
+ });
140
+ });
@@ -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
+ }