@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
|
@@ -0,0 +1,1874 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
type ComponentPropsWithoutRef,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from "react";
|
|
16
|
+
import {
|
|
17
|
+
Check,
|
|
18
|
+
ChevronLeft,
|
|
19
|
+
ChevronRight,
|
|
20
|
+
Plus,
|
|
21
|
+
Search,
|
|
22
|
+
SlidersHorizontal,
|
|
23
|
+
X,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import { Button } from "./button";
|
|
26
|
+
import { Checkbox } from "./checkbox";
|
|
27
|
+
import { DatePicker } from "./date-picker";
|
|
28
|
+
import { Input } from "./input";
|
|
29
|
+
import { Tabs, TabsList, TabsTrigger } from "./tabs";
|
|
30
|
+
import { IconButton } from "./icon-button";
|
|
31
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
32
|
+
import { Separator } from "./separator";
|
|
33
|
+
import { formatMonthLabel, MONTHS_SHORT } from "./format-datetime";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Bounds a popover by the space that is actually below (or above) the trigger,
|
|
37
|
+
* rather than by a fixed pixel cap.
|
|
38
|
+
*
|
|
39
|
+
* A chip near the bottom of a long page has very little room beneath it, and a
|
|
40
|
+
* `max-h-64` list plus a search box plus an Apply/Clear footer can easily run
|
|
41
|
+
* past the fold — the last few options, and often Apply itself, end up
|
|
42
|
+
* unreachable. Radix measures that space and publishes it as
|
|
43
|
+
* `--radix-popover-content-available-height`; capping the content to it makes
|
|
44
|
+
* the panel shrink to fit and its list scroll instead.
|
|
45
|
+
*
|
|
46
|
+
* The content becomes a flex column so the footer stays pinned to the bottom
|
|
47
|
+
* while only the list scrolls. Panels pair this with `FILTER_SCROLL_AREA`.
|
|
48
|
+
*/
|
|
49
|
+
const POPOVER_FIT = "flex max-h-[var(--radix-popover-content-available-height)] flex-col overflow-hidden";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The scrolling half of a fitted popover: it takes whatever height is left over
|
|
53
|
+
* once the fixed chrome has been laid out, never more than its own cap.
|
|
54
|
+
* `min-h-0` is what lets a flex child shrink below its content height at all.
|
|
55
|
+
*/
|
|
56
|
+
const FILTER_SCROLL_AREA = "min-h-0 flex-1 overflow-y-auto";
|
|
57
|
+
|
|
58
|
+
import { cn } from "./utils";
|
|
59
|
+
|
|
60
|
+
export interface FilterChipOption {
|
|
61
|
+
value: string;
|
|
62
|
+
label: string;
|
|
63
|
+
/** Optional leading glyph — a flag, a brand mark, a status dot. */
|
|
64
|
+
icon?: ReactNode;
|
|
65
|
+
/** Secondary text to the right, e.g. a matching count. */
|
|
66
|
+
hint?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Group: one chip open at a time, without the flash ─────────────────────────
|
|
70
|
+
|
|
71
|
+
type FilterChipGroupContextValue = {
|
|
72
|
+
openKey: string | null;
|
|
73
|
+
setOpen: (key: string, open: boolean) => void;
|
|
74
|
+
/**
|
|
75
|
+
* True exactly once, for the chip that was closed to make way for another —
|
|
76
|
+
* and clears itself on read. See `useFilterChipState`'s `onCloseAutoFocus`.
|
|
77
|
+
*/
|
|
78
|
+
consumeHandoff: (key: string) => boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const FilterChipGroupContext = createContext<FilterChipGroupContextValue | null>(null);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Wraps a row of filter chips so only one popover is open at a time — and,
|
|
85
|
+
* crucially, so switching between two chips does not make the second one flash.
|
|
86
|
+
*
|
|
87
|
+
* The flash comes from every chip sharing one `openChip` value while Radix
|
|
88
|
+
* reports the two halves of the switch as separate events: the chip being
|
|
89
|
+
* *opened* fires `onOpenChange(true)` and the chip being *dismissed* fires
|
|
90
|
+
* `onOpenChange(false)`. A naive `setOpenChip(open ? key : null)` lets whichever
|
|
91
|
+
* event lands second win, so when the dismissal lands second it wipes out the
|
|
92
|
+
* chip that just opened — it mounts, paints, and unmounts.
|
|
93
|
+
*
|
|
94
|
+
* The fix is that a close only counts if the chip closing is still the one on
|
|
95
|
+
* screen. A stale dismissal from the chip the user just left is then a no-op,
|
|
96
|
+
* whatever order the events arrive in. This lives here rather than in each
|
|
97
|
+
* toolbar because it is invisible until it is wrong, and it was wrong in every
|
|
98
|
+
* toolbar that hand-rolled it.
|
|
99
|
+
*/
|
|
100
|
+
export function FilterChipGroup({
|
|
101
|
+
children,
|
|
102
|
+
className,
|
|
103
|
+
}: {
|
|
104
|
+
children: ReactNode;
|
|
105
|
+
className?: string;
|
|
106
|
+
}) {
|
|
107
|
+
const [openKey, setOpenKey] = useState<string | null>(null);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mirrors `openKey` for synchronous reads. A switch is decided inside an
|
|
111
|
+
* event handler, where the `openKey` from this render may already be stale.
|
|
112
|
+
*/
|
|
113
|
+
const openKeyRef = useRef<string | null>(null);
|
|
114
|
+
const frameRef = useRef<number | null>(null);
|
|
115
|
+
/** The chip closed by a handoff, awaiting its close-auto-focus. */
|
|
116
|
+
const handoffFromRef = useRef<string | null>(null);
|
|
117
|
+
|
|
118
|
+
const apply = useCallback((next: string | null) => {
|
|
119
|
+
openKeyRef.current = next;
|
|
120
|
+
setOpenKey(next);
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Opening a chip while another is open is a **handoff, not a swap**: the
|
|
125
|
+
* outgoing chip closes now and the incoming one opens on the next frame, so
|
|
126
|
+
* the two popovers never exist at the same moment.
|
|
127
|
+
*
|
|
128
|
+
* Simply reassigning `openKey` looks equivalent and is not. It mounts the new
|
|
129
|
+
* popover inside the very click that closed the old one, which leaves the new
|
|
130
|
+
* layer registering its dismissal listeners mid-event and able to catch the
|
|
131
|
+
* tail of that same interaction — it opens, paints, and dismisses itself.
|
|
132
|
+
* Deferring by a frame puts the mount cleanly after the click, so there is
|
|
133
|
+
* nothing left of the previous interaction for it to react to.
|
|
134
|
+
*
|
|
135
|
+
* A close is still immediate, and only counts if the chip closing is the one
|
|
136
|
+
* actually on screen — a stale close from the chip just left is a no-op
|
|
137
|
+
* whatever order the events arrive in.
|
|
138
|
+
*/
|
|
139
|
+
const setOpen = useCallback(
|
|
140
|
+
(key: string, open: boolean) => {
|
|
141
|
+
if (frameRef.current !== null) {
|
|
142
|
+
cancelAnimationFrame(frameRef.current);
|
|
143
|
+
frameRef.current = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!open) {
|
|
147
|
+
// A stale close from the chip just left is a no-op, whatever order the
|
|
148
|
+
// events arrive in.
|
|
149
|
+
if (openKeyRef.current === key) apply(null);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (openKeyRef.current !== null && openKeyRef.current !== key) {
|
|
154
|
+
handoffFromRef.current = openKeyRef.current;
|
|
155
|
+
apply(null);
|
|
156
|
+
frameRef.current = requestAnimationFrame(() => {
|
|
157
|
+
frameRef.current = null;
|
|
158
|
+
apply(key);
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
apply(key);
|
|
164
|
+
},
|
|
165
|
+
[apply]
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// A handoff in flight when the toolbar unmounts must not fire into nothing.
|
|
169
|
+
useEffect(
|
|
170
|
+
() => () => {
|
|
171
|
+
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
|
172
|
+
},
|
|
173
|
+
[]
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const consumeHandoff = useCallback((key: string) => {
|
|
177
|
+
if (handoffFromRef.current !== key) return false;
|
|
178
|
+
handoffFromRef.current = null;
|
|
179
|
+
return true;
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
const value = useMemo(
|
|
183
|
+
() => ({ openKey, setOpen, consumeHandoff }),
|
|
184
|
+
[openKey, setOpen, consumeHandoff]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<FilterChipGroupContext.Provider value={value}>
|
|
189
|
+
<div className={cn("flex flex-wrap items-center gap-2", className)}>{children}</div>
|
|
190
|
+
</FilterChipGroupContext.Provider>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Open state for one chip. Inside a {@link FilterChipGroup} the group owns it
|
|
196
|
+
* so opening this chip closes its siblings; outside one, the chip keeps its own
|
|
197
|
+
* state, so a lone chip works with no wrapper.
|
|
198
|
+
*
|
|
199
|
+
* Every chip below calls this rather than taking `open` / `onOpenChange` props,
|
|
200
|
+
* which is what stops a call site from reintroducing the flicker by wiring the
|
|
201
|
+
* state up itself. A chip that genuinely needs outside control can still pass
|
|
202
|
+
* `open` / `onOpenChange` — a mounted-but-hidden twin of a chip, say — and
|
|
203
|
+
* those win.
|
|
204
|
+
*/
|
|
205
|
+
export function useFilterChipState(
|
|
206
|
+
key: string,
|
|
207
|
+
controlled?: { open?: boolean; onOpenChange?: (open: boolean) => void }
|
|
208
|
+
): {
|
|
209
|
+
open: boolean;
|
|
210
|
+
onOpenChange: (open: boolean) => void;
|
|
211
|
+
/** Spread onto the chip's `PopoverContent`. See below. */
|
|
212
|
+
onCloseAutoFocus: (event: Event) => void;
|
|
213
|
+
} {
|
|
214
|
+
const group = useContext(FilterChipGroupContext);
|
|
215
|
+
const [localOpen, setLocalOpen] = useState(false);
|
|
216
|
+
|
|
217
|
+
const isControlled = controlled?.open !== undefined;
|
|
218
|
+
|
|
219
|
+
const onOpenChange = useCallback(
|
|
220
|
+
(next: boolean) => {
|
|
221
|
+
controlled?.onOpenChange?.(next);
|
|
222
|
+
if (isControlled) return;
|
|
223
|
+
if (group) group.setOpen(key, next);
|
|
224
|
+
else setLocalOpen(next);
|
|
225
|
+
},
|
|
226
|
+
[controlled, isControlled, group, key]
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const open = isControlled ? controlled!.open! : group ? group.openKey === key : localOpen;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Stops a chip closed by a **handoff** from pulling focus back to its own
|
|
233
|
+
* trigger, which would land outside the chip now opening and make Radix
|
|
234
|
+
* dismiss it — the flash.
|
|
235
|
+
*
|
|
236
|
+
* Radix restores focus on close unless the popover was dismissed by an
|
|
237
|
+
* outside interaction. A handoff is neither: the group closes the chip
|
|
238
|
+
* programmatically, so as far as Radix is concerned this is an ordinary close
|
|
239
|
+
* and the trigger should get focus back. It should not — the user's attention
|
|
240
|
+
* has moved to another chip.
|
|
241
|
+
*
|
|
242
|
+
* Escape and Apply still restore focus, because neither is a handoff.
|
|
243
|
+
*/
|
|
244
|
+
const onCloseAutoFocus = useCallback(
|
|
245
|
+
(event: Event) => {
|
|
246
|
+
if (group?.consumeHandoff(key)) {
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
[group, key]
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return { open, onOpenChange, onCloseAutoFocus };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The `open` / `onOpenChange` pair every chip accepts for outside control. */
|
|
257
|
+
export interface FilterChipControl {
|
|
258
|
+
open?: boolean;
|
|
259
|
+
onOpenChange?: (open: boolean) => void;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Chip shell ────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Every filter chip is built from three pieces: this visual shell (the dashed
|
|
266
|
+
* pill), a label trigger that opens the popover, and — only once the filter has
|
|
267
|
+
* a value — a separate clear button to its left.
|
|
268
|
+
*
|
|
269
|
+
* The clear button and the trigger are two independent `<button>`s side by side
|
|
270
|
+
* rather than one button whose leading icon doubles as a clear action: clicking
|
|
271
|
+
* × must clear *without* opening the popover, and a real `<button>` cannot nest
|
|
272
|
+
* inside another. Keeping them siblings means stopping the clear click from
|
|
273
|
+
* also opening the popover needs no `stopPropagation` gymnastics — they are
|
|
274
|
+
* simply two separate click targets.
|
|
275
|
+
*
|
|
276
|
+
* Inactive it reads as an "add a filter" affordance: a dashed outline in the
|
|
277
|
+
* muted border colour. Active it flips to a solid primary ring with a tinted
|
|
278
|
+
* fill, so an applied filter is unmistakable at a glance rather than a subtle
|
|
279
|
+
* recolour of the same dashed outline.
|
|
280
|
+
*/
|
|
281
|
+
export function FilterChipShell({
|
|
282
|
+
active,
|
|
283
|
+
children,
|
|
284
|
+
className,
|
|
285
|
+
}: {
|
|
286
|
+
active: boolean;
|
|
287
|
+
children: ReactNode;
|
|
288
|
+
className?: string;
|
|
289
|
+
}) {
|
|
290
|
+
return (
|
|
291
|
+
<div
|
|
292
|
+
className={cn(
|
|
293
|
+
"inline-flex h-auto shrink-0 items-center rounded-full border border-dashed border-border bg-card shadow-sm",
|
|
294
|
+
active && "border-solid border-primary bg-primary/10 shadow-none ring-1 ring-primary/30",
|
|
295
|
+
className
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
{children}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Leading × segment, rendered only when the filter is active.
|
|
305
|
+
*
|
|
306
|
+
* A `Button` rather than an `IconButton` so the `h-auto` / `min-h-0` height
|
|
307
|
+
* override behaves the same way the label trigger's already does: IconButton
|
|
308
|
+
* sizes with Tailwind's `size-*` utility, which a plain `h-auto` does not
|
|
309
|
+
* reliably beat the way it beats Button's `h-9`.
|
|
310
|
+
*/
|
|
311
|
+
export function FilterChipClearButton({
|
|
312
|
+
label,
|
|
313
|
+
onClick,
|
|
314
|
+
}: {
|
|
315
|
+
label: string;
|
|
316
|
+
onClick: () => void;
|
|
317
|
+
}) {
|
|
318
|
+
return (
|
|
319
|
+
<Button
|
|
320
|
+
type="button"
|
|
321
|
+
variant="ghost"
|
|
322
|
+
size="sm"
|
|
323
|
+
aria-label={`Clear ${label} filter`}
|
|
324
|
+
onClick={onClick}
|
|
325
|
+
className="h-auto min-h-0 shrink-0 rounded-full border border-transparent px-2 py-1 text-primary/70 hover:text-primary"
|
|
326
|
+
>
|
|
327
|
+
<X className="h-3 w-3" />
|
|
328
|
+
</Button>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Trailing label segment — the actual `PopoverTrigger` target.
|
|
334
|
+
*
|
|
335
|
+
* A leading plus shows only while inactive, since once active the clear button
|
|
336
|
+
* to its left already carries a leading icon. A trailing dot then marks
|
|
337
|
+
* "active"; `count` replaces it with a number when *how many* values are
|
|
338
|
+
* applied is worth saying.
|
|
339
|
+
*
|
|
340
|
+
* Must forward its ref and spread the rest of its props onto the underlying
|
|
341
|
+
* Button: `PopoverTrigger asChild` clones its single child to inject
|
|
342
|
+
* onClick/ref/aria-*, and a component that swallows those renders a chip that
|
|
343
|
+
* looks right and does nothing when clicked.
|
|
344
|
+
*/
|
|
345
|
+
export const FilterChipLabelTrigger = forwardRef<
|
|
346
|
+
HTMLButtonElement,
|
|
347
|
+
{
|
|
348
|
+
label: string;
|
|
349
|
+
active: boolean;
|
|
350
|
+
/** Show this number instead of the plain active dot. */
|
|
351
|
+
count?: number;
|
|
352
|
+
} & Omit<ComponentPropsWithoutRef<typeof Button>, "children">
|
|
353
|
+
>(({ label, active, count, className, ...props }, ref) => (
|
|
354
|
+
<Button
|
|
355
|
+
ref={ref}
|
|
356
|
+
type="button"
|
|
357
|
+
variant="ghost"
|
|
358
|
+
size="sm"
|
|
359
|
+
leftIcon={
|
|
360
|
+
!active ? (
|
|
361
|
+
<span className="flex h-3.5 w-3.5 items-center justify-center">
|
|
362
|
+
<Plus className="h-3 w-3" />
|
|
363
|
+
</span>
|
|
364
|
+
) : undefined
|
|
365
|
+
}
|
|
366
|
+
rightIcon={
|
|
367
|
+
active ? (
|
|
368
|
+
<span className="flex h-3.5 items-center justify-center">
|
|
369
|
+
{count != null && count > 0 ? (
|
|
370
|
+
<span className="rounded-full bg-primary/15 px-1.5 text-[10px] font-semibold tabular-nums text-primary">
|
|
371
|
+
{count}
|
|
372
|
+
</span>
|
|
373
|
+
) : (
|
|
374
|
+
/* Drawn the way Badge draws its dot: the same size-1.5 rounded
|
|
375
|
+
fill in the primary accent. A state marker, not a count. */
|
|
376
|
+
<span className="size-1.5 rounded-full bg-primary" aria-hidden />
|
|
377
|
+
)}
|
|
378
|
+
</span>
|
|
379
|
+
) : undefined
|
|
380
|
+
}
|
|
381
|
+
className={cn(
|
|
382
|
+
"h-auto min-h-0 shrink-0 rounded-full border border-transparent py-1",
|
|
383
|
+
active
|
|
384
|
+
? "pl-1.5 pr-2.5 font-semibold text-primary hover:text-primary"
|
|
385
|
+
: "pl-2.5 pr-2.5 text-muted-foreground hover:text-foreground",
|
|
386
|
+
className
|
|
387
|
+
)}
|
|
388
|
+
{...props}
|
|
389
|
+
>
|
|
390
|
+
{label}
|
|
391
|
+
</Button>
|
|
392
|
+
));
|
|
393
|
+
FilterChipLabelTrigger.displayName = "FilterChipLabelTrigger";
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Apply / Clear footer shared by every panel, so the two buttons sit in the
|
|
397
|
+
* same place and read the same wherever a chip's editor puts them.
|
|
398
|
+
*
|
|
399
|
+
* Both buttons **commit and close**. Clear is not "untick everything and let me
|
|
400
|
+
* carry on" — that reading leaves the panel open over a filter that is still
|
|
401
|
+
* applied, so the chip still reads "Type 1" while the list in front of you
|
|
402
|
+
* shows nothing ticked, and closing the panel silently keeps the old filter.
|
|
403
|
+
* Clear is the same act as the chip's own little x, reached from inside the
|
|
404
|
+
* panel: it drops the filter and gets out of the way.
|
|
405
|
+
*/
|
|
406
|
+
export function FilterChipActions({
|
|
407
|
+
onClear,
|
|
408
|
+
onApply,
|
|
409
|
+
clearDisabled,
|
|
410
|
+
applyDisabled,
|
|
411
|
+
}: {
|
|
412
|
+
onClear: () => void;
|
|
413
|
+
onApply: () => void;
|
|
414
|
+
clearDisabled?: boolean;
|
|
415
|
+
applyDisabled?: boolean;
|
|
416
|
+
}) {
|
|
417
|
+
return (
|
|
418
|
+
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2">
|
|
419
|
+
<Button
|
|
420
|
+
type="button"
|
|
421
|
+
variant="ghost"
|
|
422
|
+
size="sm"
|
|
423
|
+
onClick={onClear}
|
|
424
|
+
disabled={clearDisabled}
|
|
425
|
+
className="text-muted-foreground hover:text-foreground"
|
|
426
|
+
>
|
|
427
|
+
Clear
|
|
428
|
+
</Button>
|
|
429
|
+
<Button type="button" variant="primary" size="sm" onClick={onApply} disabled={applyDisabled}>
|
|
430
|
+
Apply
|
|
431
|
+
</Button>
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The full chip — shell, clear button, trigger and popover — with the editor
|
|
438
|
+
* supplied as `children`. Build a bespoke chip on this rather than reassembling
|
|
439
|
+
* the pieces, so a one-off filter still opens, closes and clears like the rest.
|
|
440
|
+
*/
|
|
441
|
+
export function FilterChip({
|
|
442
|
+
chipKey,
|
|
443
|
+
label,
|
|
444
|
+
active,
|
|
445
|
+
count,
|
|
446
|
+
onClear,
|
|
447
|
+
children,
|
|
448
|
+
align = "start",
|
|
449
|
+
contentClassName,
|
|
450
|
+
open: controlledOpen,
|
|
451
|
+
onOpenChange: controlledOnOpenChange,
|
|
452
|
+
onOpen,
|
|
453
|
+
}: {
|
|
454
|
+
/** Identity within a {@link FilterChipGroup}. Must be unique in the row. */
|
|
455
|
+
chipKey: string;
|
|
456
|
+
label: string;
|
|
457
|
+
active: boolean;
|
|
458
|
+
count?: number;
|
|
459
|
+
/** Omit to hide the × segment — for a chip that cannot be emptied. */
|
|
460
|
+
onClear?: () => void;
|
|
461
|
+
children: ReactNode;
|
|
462
|
+
align?: "start" | "center" | "end";
|
|
463
|
+
contentClassName?: string;
|
|
464
|
+
/** Fires when the popover opens — the hook for seeding a draft from the
|
|
465
|
+
* applied value, so an abandoned edit never leaks into the next open. */
|
|
466
|
+
onOpen?: () => void;
|
|
467
|
+
} & FilterChipControl) {
|
|
468
|
+
const { open, onOpenChange, onCloseAutoFocus } = useFilterChipState(chipKey, {
|
|
469
|
+
open: controlledOpen,
|
|
470
|
+
onOpenChange: controlledOnOpenChange,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<Popover
|
|
475
|
+
open={open}
|
|
476
|
+
onOpenChange={(next) => {
|
|
477
|
+
onOpenChange(next);
|
|
478
|
+
if (next) onOpen?.();
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
<FilterChipShell active={active}>
|
|
482
|
+
{active && onClear ? (
|
|
483
|
+
<FilterChipClearButton
|
|
484
|
+
label={label}
|
|
485
|
+
onClick={() => {
|
|
486
|
+
onClear();
|
|
487
|
+
onOpenChange(false);
|
|
488
|
+
}}
|
|
489
|
+
/>
|
|
490
|
+
) : null}
|
|
491
|
+
<PopoverTrigger asChild>
|
|
492
|
+
<FilterChipLabelTrigger label={label} active={active} count={count} />
|
|
493
|
+
</PopoverTrigger>
|
|
494
|
+
</FilterChipShell>
|
|
495
|
+
<PopoverContent
|
|
496
|
+
align={align}
|
|
497
|
+
className={cn("w-auto p-0", POPOVER_FIT, contentClassName)}
|
|
498
|
+
onCloseAutoFocus={onCloseAutoFocus}
|
|
499
|
+
>
|
|
500
|
+
{children}
|
|
501
|
+
</PopoverContent>
|
|
502
|
+
</Popover>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── Multi-select chip ─────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
export interface SelectFilterChipProps extends FilterChipControl {
|
|
509
|
+
/** Identity within a group. Defaults to `label`. */
|
|
510
|
+
chipKey?: string;
|
|
511
|
+
label: string;
|
|
512
|
+
options: FilterChipOption[];
|
|
513
|
+
selected: string[];
|
|
514
|
+
onChange: (next: string[]) => void;
|
|
515
|
+
/** Show a search box above the list once there are this many options. Default 8. */
|
|
516
|
+
searchThreshold?: number;
|
|
517
|
+
/** Show the applied count on the chip instead of the plain active dot. */
|
|
518
|
+
showCount?: boolean;
|
|
519
|
+
/**
|
|
520
|
+
* Adds an "Invert filter" tick below the list, turning the chosen set into an
|
|
521
|
+
* exclusion. Pass both to enable it; omit for a plain include-only chip.
|
|
522
|
+
*
|
|
523
|
+
* It is staged with the options and applied with them, because inverting
|
|
524
|
+
* without changing the set is still a change to what the table shows, and
|
|
525
|
+
* committing it on the tick would make this one control in the panel behave
|
|
526
|
+
* differently from the rest.
|
|
527
|
+
*/
|
|
528
|
+
invert?: boolean;
|
|
529
|
+
onInvertChange?: (next: boolean) => void;
|
|
530
|
+
/** Label for the invert tick. Default "Invert filter". */
|
|
531
|
+
invertLabel?: string;
|
|
532
|
+
/** Empty-list line, for options that arrive from a request. */
|
|
533
|
+
emptyText?: string;
|
|
534
|
+
align?: "start" | "center" | "end";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The workhorse chip: a checkbox list staged behind Apply, so ticking four
|
|
539
|
+
* boxes is one query rather than four. Escaping or clicking away discards the
|
|
540
|
+
* draft — the applied value only changes on Apply or Clear.
|
|
541
|
+
*/
|
|
542
|
+
export function SelectFilterChip({
|
|
543
|
+
chipKey,
|
|
544
|
+
label,
|
|
545
|
+
options,
|
|
546
|
+
selected,
|
|
547
|
+
onChange,
|
|
548
|
+
searchThreshold = 8,
|
|
549
|
+
showCount = true,
|
|
550
|
+
invert = false,
|
|
551
|
+
onInvertChange,
|
|
552
|
+
invertLabel = "Invert filter",
|
|
553
|
+
emptyText,
|
|
554
|
+
align = "start",
|
|
555
|
+
open,
|
|
556
|
+
onOpenChange,
|
|
557
|
+
}: SelectFilterChipProps) {
|
|
558
|
+
const key = chipKey ?? label;
|
|
559
|
+
// Resolved here, not just inside `FilterChip`, because this component's own
|
|
560
|
+
// Apply and Clear need to close the popover — and under a `FilterChipGroup`
|
|
561
|
+
// the `onOpenChange` prop is undefined, since the group owns that state.
|
|
562
|
+
const chip = useFilterChipState(key, { open, onOpenChange });
|
|
563
|
+
const [draft, setDraft] = useState<string[]>(selected);
|
|
564
|
+
const [draftInvert, setDraftInvert] = useState(invert);
|
|
565
|
+
const [query, setQuery] = useState("");
|
|
566
|
+
const supportsInvert = !!onInvertChange;
|
|
567
|
+
// An inversion with nothing chosen excludes nothing, so it is not on its own
|
|
568
|
+
// an active filter.
|
|
569
|
+
const isActive = selected.length > 0;
|
|
570
|
+
|
|
571
|
+
// Matched on the value as well as the label, so "nz" finds New Zealand and a
|
|
572
|
+
// raw status code finds its prettified row. Someone who thinks in codes should
|
|
573
|
+
// not have to know the display name.
|
|
574
|
+
const visible = useMemo(() => {
|
|
575
|
+
const q = query.trim().toLowerCase();
|
|
576
|
+
if (!q) return options;
|
|
577
|
+
return options.filter(
|
|
578
|
+
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)
|
|
579
|
+
);
|
|
580
|
+
}, [options, query]);
|
|
581
|
+
|
|
582
|
+
const toggle = (value: string) =>
|
|
583
|
+
setDraft((prev) =>
|
|
584
|
+
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<FilterChip
|
|
589
|
+
chipKey={key}
|
|
590
|
+
// An inverted chip says so on the pill: "Status Code" and "Status Code
|
|
591
|
+
// (excluded)" filter to opposite halves of the same data, and a shared
|
|
592
|
+
// count would otherwise be the only difference between them.
|
|
593
|
+
label={supportsInvert && invert && isActive ? `${label} (excluded)` : label}
|
|
594
|
+
active={isActive}
|
|
595
|
+
count={showCount ? selected.length : undefined}
|
|
596
|
+
align={align}
|
|
597
|
+
open={chip.open}
|
|
598
|
+
onOpenChange={chip.onOpenChange}
|
|
599
|
+
// Reseed from the applied value on every open, so a draft abandoned last
|
|
600
|
+
// time does not reappear as though it had been applied.
|
|
601
|
+
onOpen={() => {
|
|
602
|
+
setDraft(selected);
|
|
603
|
+
setDraftInvert(invert);
|
|
604
|
+
setQuery("");
|
|
605
|
+
}}
|
|
606
|
+
onClear={() => {
|
|
607
|
+
onChange([]);
|
|
608
|
+
setDraft([]);
|
|
609
|
+
onInvertChange?.(false);
|
|
610
|
+
setDraftInvert(false);
|
|
611
|
+
}}
|
|
612
|
+
>
|
|
613
|
+
<FilterChipPanel
|
|
614
|
+
options={visible}
|
|
615
|
+
draft={draft}
|
|
616
|
+
onToggle={toggle}
|
|
617
|
+
query={options.length >= searchThreshold ? query : undefined}
|
|
618
|
+
onQueryChange={setQuery}
|
|
619
|
+
searchPlaceholder={`Search ${label.toLowerCase()}`}
|
|
620
|
+
emptyText={emptyText}
|
|
621
|
+
invert={supportsInvert ? draftInvert : undefined}
|
|
622
|
+
onInvertChange={setDraftInvert}
|
|
623
|
+
invertLabel={invertLabel}
|
|
624
|
+
/>
|
|
625
|
+
<FilterChipActions
|
|
626
|
+
onClear={() => {
|
|
627
|
+
onChange([]);
|
|
628
|
+
setDraft([]);
|
|
629
|
+
onInvertChange?.(false);
|
|
630
|
+
setDraftInvert(false);
|
|
631
|
+
chip.onOpenChange(false);
|
|
632
|
+
}}
|
|
633
|
+
// Disabled only when there is genuinely nothing to drop: an applied
|
|
634
|
+
// value with an emptied draft is still something to clear.
|
|
635
|
+
clearDisabled={draft.length === 0 && selected.length === 0 && !draftInvert && !invert}
|
|
636
|
+
onApply={() => {
|
|
637
|
+
onChange(draft);
|
|
638
|
+
onInvertChange?.(draftInvert);
|
|
639
|
+
chip.onOpenChange(false);
|
|
640
|
+
}}
|
|
641
|
+
/>
|
|
642
|
+
</FilterChip>
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** The checkbox list itself, reused by the multi-select chip and the add menu. */
|
|
647
|
+
function FilterChipPanel({
|
|
648
|
+
options,
|
|
649
|
+
draft,
|
|
650
|
+
onToggle,
|
|
651
|
+
query,
|
|
652
|
+
onQueryChange,
|
|
653
|
+
searchPlaceholder,
|
|
654
|
+
emptyText,
|
|
655
|
+
invert,
|
|
656
|
+
onInvertChange,
|
|
657
|
+
invertLabel,
|
|
658
|
+
}: {
|
|
659
|
+
options: FilterChipOption[];
|
|
660
|
+
draft: string[];
|
|
661
|
+
onToggle: (value: string) => void;
|
|
662
|
+
query?: string;
|
|
663
|
+
onQueryChange: (q: string) => void;
|
|
664
|
+
searchPlaceholder: string;
|
|
665
|
+
emptyText?: string;
|
|
666
|
+
/** Undefined hides the invert row entirely. */
|
|
667
|
+
invert?: boolean;
|
|
668
|
+
onInvertChange: (next: boolean) => void;
|
|
669
|
+
invertLabel?: string;
|
|
670
|
+
}) {
|
|
671
|
+
return (
|
|
672
|
+
<div className="flex min-h-0 w-60 flex-1 flex-col">
|
|
673
|
+
{query !== undefined ? (
|
|
674
|
+
<div className="shrink-0 border-b border-border p-2">
|
|
675
|
+
<Input
|
|
676
|
+
value={query}
|
|
677
|
+
onChange={(e) => onQueryChange(e.target.value)}
|
|
678
|
+
placeholder={searchPlaceholder}
|
|
679
|
+
className="h-8 text-[12.5px]"
|
|
680
|
+
/>
|
|
681
|
+
</div>
|
|
682
|
+
) : null}
|
|
683
|
+
<div className={cn("max-h-64 space-y-0.5 p-2", FILTER_SCROLL_AREA)}>
|
|
684
|
+
{options.length === 0 ? (
|
|
685
|
+
<p className="px-1 py-4 text-center text-[12.5px] text-muted-foreground">
|
|
686
|
+
{emptyText ?? "No matches"}
|
|
687
|
+
</p>
|
|
688
|
+
) : (
|
|
689
|
+
options.map((option) => (
|
|
690
|
+
<label
|
|
691
|
+
key={option.value}
|
|
692
|
+
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-1 text-[12.5px] text-foreground hover:bg-muted/50"
|
|
693
|
+
>
|
|
694
|
+
<Checkbox
|
|
695
|
+
checked={draft.includes(option.value)}
|
|
696
|
+
onCheckedChange={() => onToggle(option.value)}
|
|
697
|
+
/>
|
|
698
|
+
{option.icon}
|
|
699
|
+
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
|
700
|
+
{option.hint ? (
|
|
701
|
+
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
|
702
|
+
{option.hint}
|
|
703
|
+
</span>
|
|
704
|
+
) : null}
|
|
705
|
+
</label>
|
|
706
|
+
))
|
|
707
|
+
)}
|
|
708
|
+
</div>
|
|
709
|
+
|
|
710
|
+
{invert !== undefined ? (
|
|
711
|
+
<label className="flex shrink-0 cursor-pointer items-center gap-2 border-t border-border px-3 py-2 text-[12.5px] text-foreground">
|
|
712
|
+
<Checkbox checked={invert} onCheckedChange={(c) => onInvertChange(c === true)} />
|
|
713
|
+
{invertLabel}
|
|
714
|
+
</label>
|
|
715
|
+
) : null}
|
|
716
|
+
</div>
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── Single-select chip ────────────────────────────────────────────────────────
|
|
721
|
+
|
|
722
|
+
export interface SingleSelectFilterChipProps extends FilterChipControl {
|
|
723
|
+
chipKey?: string;
|
|
724
|
+
label: string;
|
|
725
|
+
options: FilterChipOption[];
|
|
726
|
+
value: string;
|
|
727
|
+
onChange: (next: string) => void;
|
|
728
|
+
align?: "start" | "center" | "end";
|
|
729
|
+
/** Show the chosen option's label on the chip instead of the field name. */
|
|
730
|
+
showValueInLabel?: boolean;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* One-of-many. Picking applies immediately — there is nothing to stage when a
|
|
735
|
+
* choice replaces rather than accumulates, and an Apply button for a single
|
|
736
|
+
* click is a step that only costs the user time.
|
|
737
|
+
*/
|
|
738
|
+
export function SingleSelectFilterChip({
|
|
739
|
+
chipKey,
|
|
740
|
+
label,
|
|
741
|
+
options,
|
|
742
|
+
value,
|
|
743
|
+
onChange,
|
|
744
|
+
align = "start",
|
|
745
|
+
showValueInLabel = false,
|
|
746
|
+
open,
|
|
747
|
+
onOpenChange,
|
|
748
|
+
}: SingleSelectFilterChipProps) {
|
|
749
|
+
const key = chipKey ?? label;
|
|
750
|
+
// See SelectFilterChip: picking an option has to close the popover itself.
|
|
751
|
+
const chip = useFilterChipState(key, { open, onOpenChange });
|
|
752
|
+
const isActive = value !== "";
|
|
753
|
+
const chosen = options.find((o) => o.value === value);
|
|
754
|
+
|
|
755
|
+
return (
|
|
756
|
+
<FilterChip
|
|
757
|
+
chipKey={key}
|
|
758
|
+
label={showValueInLabel && chosen ? `${label}: ${chosen.label}` : label}
|
|
759
|
+
active={isActive}
|
|
760
|
+
align={align}
|
|
761
|
+
open={chip.open}
|
|
762
|
+
onOpenChange={chip.onOpenChange}
|
|
763
|
+
onClear={() => onChange("")}
|
|
764
|
+
>
|
|
765
|
+
<div className={cn("max-h-64 w-52 p-1.5", FILTER_SCROLL_AREA)}>
|
|
766
|
+
{options.map((option) => (
|
|
767
|
+
<Button
|
|
768
|
+
key={option.value}
|
|
769
|
+
type="button"
|
|
770
|
+
variant="ghost"
|
|
771
|
+
size="sm"
|
|
772
|
+
onClick={() => {
|
|
773
|
+
onChange(option.value);
|
|
774
|
+
chip.onOpenChange(false);
|
|
775
|
+
}}
|
|
776
|
+
className={cn(
|
|
777
|
+
"w-full justify-start rounded-md px-2 py-1.5 text-[12.5px] font-normal",
|
|
778
|
+
option.value === value && "bg-primary/10 font-medium text-primary"
|
|
779
|
+
)}
|
|
780
|
+
>
|
|
781
|
+
{option.icon}
|
|
782
|
+
<span className="truncate">{option.label}</span>
|
|
783
|
+
</Button>
|
|
784
|
+
))}
|
|
785
|
+
</div>
|
|
786
|
+
</FilterChip>
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ── Date-range chip ───────────────────────────────────────────────────────────
|
|
791
|
+
|
|
792
|
+
export interface DateRangeValue {
|
|
793
|
+
/** `yyyy-mm-dd`, or "" for unset. */
|
|
794
|
+
from: string;
|
|
795
|
+
to: string;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* `DatePicker` renders `<div className={cn("relative", className)}>` with its
|
|
800
|
+
* trigger `<button>` as the direct child, and hard-codes a tall trigger
|
|
801
|
+
* (`h-12 rounded-xl px-5 text-[15px]`) sized for a form field. Inside a chip
|
|
802
|
+
* popover that towers over everything around it, so `[&>button]` reaches the
|
|
803
|
+
* trigger and brings it back to the size of the console's other dropdowns.
|
|
804
|
+
*/
|
|
805
|
+
const DATE_CHIP_TRIGGER =
|
|
806
|
+
"[&>button]:!h-10 [&>button]:!min-h-0 [&>button]:!gap-2 [&>button]:!rounded-lg [&>button]:!px-3.5 [&>button]:!text-sm [&>button]:!font-normal [&>button]:!shadow-none [&>button>svg]:!size-4";
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* "Last N weeks / days / hours / minutes", counted back from now.
|
|
810
|
+
*
|
|
811
|
+
* A duration rather than a pair of dates, because that is what it is: "last 2
|
|
812
|
+
* days" means two days before *now*, and resolving it to fixed timestamps when
|
|
813
|
+
* the user picks it quietly freezes it at the moment of the click. The chip
|
|
814
|
+
* reports the duration; the caller resolves it at request time with
|
|
815
|
+
* {@link relativeRangeToMillis}.
|
|
816
|
+
*
|
|
817
|
+
* Every field is a string because each is a text input, and "" is a field the
|
|
818
|
+
* user has not filled in — distinct from "0".
|
|
819
|
+
*/
|
|
820
|
+
export interface RelativeRangeValue {
|
|
821
|
+
weeks: string;
|
|
822
|
+
days: string;
|
|
823
|
+
hours: string;
|
|
824
|
+
minutes: string;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export const EMPTY_RELATIVE_RANGE: RelativeRangeValue = {
|
|
828
|
+
weeks: "",
|
|
829
|
+
days: "",
|
|
830
|
+
hours: "",
|
|
831
|
+
minutes: "",
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const RELATIVE_UNITS: {
|
|
835
|
+
key: keyof RelativeRangeValue;
|
|
836
|
+
label: string;
|
|
837
|
+
seconds: number;
|
|
838
|
+
}[] = [
|
|
839
|
+
{ key: "weeks", label: "Weeks", seconds: 7 * 24 * 60 * 60 },
|
|
840
|
+
{ key: "days", label: "Days", seconds: 24 * 60 * 60 },
|
|
841
|
+
{ key: "hours", label: "Hours", seconds: 60 * 60 },
|
|
842
|
+
{ key: "minutes", label: "Minutes", seconds: 60 },
|
|
843
|
+
];
|
|
844
|
+
|
|
845
|
+
function relativeRangeSeconds(value: RelativeRangeValue): number {
|
|
846
|
+
return RELATIVE_UNITS.reduce((total, unit) => {
|
|
847
|
+
const parsed = parseInt(value[unit.key] || "0", 10);
|
|
848
|
+
return total + (Number.isNaN(parsed) ? 0 : parsed) * unit.seconds;
|
|
849
|
+
}, 0);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Whether a relative range names any span at all. */
|
|
853
|
+
export function hasRelativeRange(value: RelativeRangeValue | undefined): boolean {
|
|
854
|
+
return !!value && relativeRangeSeconds(value) > 0;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Resolves a relative range to absolute epoch millis, evaluated at call time.
|
|
859
|
+
*
|
|
860
|
+
* Deliberately not memoised and never computed during render: "last 2 days"
|
|
861
|
+
* means two days before now, and now moves. Call it in the handler that builds
|
|
862
|
+
* the request.
|
|
863
|
+
*/
|
|
864
|
+
export function relativeRangeToMillis(
|
|
865
|
+
value: RelativeRangeValue
|
|
866
|
+
): { startTime: number; endTime: number } | null {
|
|
867
|
+
const seconds = relativeRangeSeconds(value);
|
|
868
|
+
if (seconds <= 0) return null;
|
|
869
|
+
const endTime = Date.now();
|
|
870
|
+
return { startTime: endTime - seconds * 1000, endTime };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** A relative range as a chip label: `Last 2d 6h`. */
|
|
874
|
+
function relativeRangeLabel(value: RelativeRangeValue): string {
|
|
875
|
+
const parts = RELATIVE_UNITS.flatMap((unit) => {
|
|
876
|
+
const n = parseInt(value[unit.key] || "0", 10);
|
|
877
|
+
return !n || Number.isNaN(n) ? [] : [`${n}${unit.key[0]}`];
|
|
878
|
+
});
|
|
879
|
+
return parts.length ? `Last ${parts.join(" ")}` : "";
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export interface DateRangeFilterChipProps extends FilterChipControl {
|
|
883
|
+
chipKey?: string;
|
|
884
|
+
label?: string;
|
|
885
|
+
value: DateRangeValue;
|
|
886
|
+
onChange: (next: DateRangeValue) => void;
|
|
887
|
+
/**
|
|
888
|
+
* Turns on the "Last…" tab beside the date range. Omit both and the chip is
|
|
889
|
+
* absolute-only.
|
|
890
|
+
*
|
|
891
|
+
* The two modes are exclusive by construction: applying one clears the other,
|
|
892
|
+
* because a range that is both "last 7 days" and "1–31 Jan" cannot be
|
|
893
|
+
* honoured and nothing downstream should have to guess which half won.
|
|
894
|
+
*/
|
|
895
|
+
relativeValue?: RelativeRangeValue;
|
|
896
|
+
onRelativeChange?: (next: RelativeRangeValue) => void;
|
|
897
|
+
/** Earliest / latest selectable date, `YYYY-MM-DD`. */
|
|
898
|
+
min?: string;
|
|
899
|
+
max?: string;
|
|
900
|
+
/** Shown under the fields when a picked date falls outside `min`/`max`. */
|
|
901
|
+
outOfRangeHint?: string;
|
|
902
|
+
align?: "start" | "center" | "end";
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* From / To, staged behind Apply. A half-filled range cannot be applied: an
|
|
907
|
+
* open-ended date filter reads as a bug far more often than it is what someone
|
|
908
|
+
* meant, and the disabled Apply says so without an error message.
|
|
909
|
+
*/
|
|
910
|
+
export function DateRangeFilterChip({
|
|
911
|
+
chipKey = "date",
|
|
912
|
+
label = "Date",
|
|
913
|
+
value,
|
|
914
|
+
onChange,
|
|
915
|
+
relativeValue,
|
|
916
|
+
onRelativeChange,
|
|
917
|
+
min,
|
|
918
|
+
max,
|
|
919
|
+
outOfRangeHint,
|
|
920
|
+
align = "start",
|
|
921
|
+
open,
|
|
922
|
+
onOpenChange,
|
|
923
|
+
}: DateRangeFilterChipProps) {
|
|
924
|
+
const chip = useFilterChipState(chipKey, { open, onOpenChange });
|
|
925
|
+
const [draft, setDraft] = useState<DateRangeValue>(value);
|
|
926
|
+
const [relativeDraft, setRelativeDraft] = useState<RelativeRangeValue>(
|
|
927
|
+
relativeValue ?? EMPTY_RELATIVE_RANGE
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
// The tab the panel opens on follows what is applied, so reopening a chip set
|
|
931
|
+
// to "last 2 days" does not land on an empty date range.
|
|
932
|
+
const supportsRelative = !!onRelativeChange;
|
|
933
|
+
const [mode, setMode] = useState<"absolute" | "relative">(
|
|
934
|
+
hasRelativeRange(relativeValue) ? "relative" : "absolute"
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
const relativeLabel = hasRelativeRange(relativeValue)
|
|
938
|
+
? relativeRangeLabel(relativeValue!)
|
|
939
|
+
: "";
|
|
940
|
+
const isActive = !!(value.from && value.to) || !!relativeLabel;
|
|
941
|
+
const isPartial = !!draft.from !== !!draft.to;
|
|
942
|
+
|
|
943
|
+
const outside = (d: string) => !!d && ((!!min && d < min) || (!!max && d > max));
|
|
944
|
+
const outOfRange = outside(draft.from) || outside(draft.to);
|
|
945
|
+
|
|
946
|
+
const emptyRange: DateRangeValue = { from: "", to: "" };
|
|
947
|
+
const isRelative = supportsRelative && mode === "relative";
|
|
948
|
+
|
|
949
|
+
// Applying one mode clears the other, so the applied value is never both a
|
|
950
|
+
// duration and a pair of dates.
|
|
951
|
+
const commit = (range: DateRangeValue, relative: RelativeRangeValue) => {
|
|
952
|
+
onChange(range);
|
|
953
|
+
onRelativeChange?.(relative);
|
|
954
|
+
setDraft(range);
|
|
955
|
+
setRelativeDraft(relative);
|
|
956
|
+
chip.onOpenChange(false);
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
return (
|
|
960
|
+
<FilterChip
|
|
961
|
+
chipKey={chipKey}
|
|
962
|
+
// The chip carries the applied span, not just the word "Date": it is in
|
|
963
|
+
// the request, so it should be readable without opening anything.
|
|
964
|
+
label={
|
|
965
|
+
relativeLabel
|
|
966
|
+
? `${label}: ${relativeLabel}`
|
|
967
|
+
: isActive
|
|
968
|
+
? `${label}: ${value.from} → ${value.to}`
|
|
969
|
+
: label
|
|
970
|
+
}
|
|
971
|
+
active={isActive}
|
|
972
|
+
align={align}
|
|
973
|
+
open={chip.open}
|
|
974
|
+
onOpenChange={chip.onOpenChange}
|
|
975
|
+
onOpen={() => {
|
|
976
|
+
setDraft(value);
|
|
977
|
+
setRelativeDraft(relativeValue ?? EMPTY_RELATIVE_RANGE);
|
|
978
|
+
setMode(hasRelativeRange(relativeValue) ? "relative" : "absolute");
|
|
979
|
+
}}
|
|
980
|
+
onClear={() => {
|
|
981
|
+
onChange(emptyRange);
|
|
982
|
+
onRelativeChange?.(EMPTY_RELATIVE_RANGE);
|
|
983
|
+
setDraft(emptyRange);
|
|
984
|
+
setRelativeDraft(EMPTY_RELATIVE_RANGE);
|
|
985
|
+
}}
|
|
986
|
+
>
|
|
987
|
+
<div className="w-72 space-y-3 p-3">
|
|
988
|
+
{supportsRelative ? (
|
|
989
|
+
<Tabs value={mode} onValueChange={(v) => setMode(v as "absolute" | "relative")}>
|
|
990
|
+
<TabsList className="w-full">
|
|
991
|
+
<TabsTrigger value="absolute" className="flex-1">
|
|
992
|
+
Date range
|
|
993
|
+
</TabsTrigger>
|
|
994
|
+
<TabsTrigger value="relative" className="flex-1">
|
|
995
|
+
Last…
|
|
996
|
+
</TabsTrigger>
|
|
997
|
+
</TabsList>
|
|
998
|
+
</Tabs>
|
|
999
|
+
) : null}
|
|
1000
|
+
|
|
1001
|
+
{isRelative ? (
|
|
1002
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1003
|
+
{RELATIVE_UNITS.map((unit) => (
|
|
1004
|
+
<label key={unit.key} className="space-y-1.5">
|
|
1005
|
+
<span className="text-[11px] font-medium text-muted-foreground">{unit.label}</span>
|
|
1006
|
+
<Input
|
|
1007
|
+
type="number"
|
|
1008
|
+
min={0}
|
|
1009
|
+
inputMode="numeric"
|
|
1010
|
+
placeholder="0"
|
|
1011
|
+
value={relativeDraft[unit.key]}
|
|
1012
|
+
onChange={(e) =>
|
|
1013
|
+
setRelativeDraft((prev) => ({ ...prev, [unit.key]: e.target.value }))
|
|
1014
|
+
}
|
|
1015
|
+
className="h-8 text-[12.5px]"
|
|
1016
|
+
/>
|
|
1017
|
+
</label>
|
|
1018
|
+
))}
|
|
1019
|
+
</div>
|
|
1020
|
+
) : (
|
|
1021
|
+
<>
|
|
1022
|
+
<div className="space-y-1">
|
|
1023
|
+
<p className="text-[11px] font-medium text-muted-foreground">From</p>
|
|
1024
|
+
<DatePicker
|
|
1025
|
+
value={draft.from}
|
|
1026
|
+
onChange={(v) => setDraft((d) => ({ ...d, from: v }))}
|
|
1027
|
+
min={min}
|
|
1028
|
+
max={max}
|
|
1029
|
+
placeholder="Select start date"
|
|
1030
|
+
className={DATE_CHIP_TRIGGER}
|
|
1031
|
+
/>
|
|
1032
|
+
</div>
|
|
1033
|
+
<div className="space-y-1">
|
|
1034
|
+
<p className="text-[11px] font-medium text-muted-foreground">To</p>
|
|
1035
|
+
<DatePicker
|
|
1036
|
+
value={draft.to}
|
|
1037
|
+
onChange={(v) => setDraft((d) => ({ ...d, to: v }))}
|
|
1038
|
+
min={draft.from || min}
|
|
1039
|
+
max={max}
|
|
1040
|
+
placeholder="Select end date"
|
|
1041
|
+
className={DATE_CHIP_TRIGGER}
|
|
1042
|
+
/>
|
|
1043
|
+
</div>
|
|
1044
|
+
{isPartial ? (
|
|
1045
|
+
<p className="text-[11px] text-muted-foreground">Pick both ends of the range.</p>
|
|
1046
|
+
) : null}
|
|
1047
|
+
{outOfRange && outOfRangeHint ? (
|
|
1048
|
+
<p className="text-[11px] text-destructive">{outOfRangeHint}</p>
|
|
1049
|
+
) : null}
|
|
1050
|
+
</>
|
|
1051
|
+
)}
|
|
1052
|
+
</div>
|
|
1053
|
+
<FilterChipActions
|
|
1054
|
+
onClear={() => commit(emptyRange, EMPTY_RELATIVE_RANGE)}
|
|
1055
|
+
clearDisabled={
|
|
1056
|
+
!draft.from &&
|
|
1057
|
+
!draft.to &&
|
|
1058
|
+
!hasRelativeRange(relativeDraft) &&
|
|
1059
|
+
!value.from &&
|
|
1060
|
+
!value.to &&
|
|
1061
|
+
!relativeLabel
|
|
1062
|
+
}
|
|
1063
|
+
// A duration needs no dates; a date range needs both ends and must sit
|
|
1064
|
+
// inside the allowed window.
|
|
1065
|
+
applyDisabled={
|
|
1066
|
+
isRelative ? !hasRelativeRange(relativeDraft) : isPartial || outOfRange
|
|
1067
|
+
}
|
|
1068
|
+
onApply={() =>
|
|
1069
|
+
isRelative
|
|
1070
|
+
? commit(emptyRange, relativeDraft)
|
|
1071
|
+
: commit(draft, EMPTY_RELATIVE_RANGE)
|
|
1072
|
+
}
|
|
1073
|
+
/>
|
|
1074
|
+
</FilterChip>
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ── Month-range chip ──────────────────────────────────────────────────────────
|
|
1079
|
+
|
|
1080
|
+
export interface MonthRange {
|
|
1081
|
+
/** Inclusive "YYYY-MM" bounds. Both ends compare as plain strings. */
|
|
1082
|
+
start: string;
|
|
1083
|
+
end: string;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** A month index within a year → the "YYYY-MM" key the filter stores. */
|
|
1087
|
+
const monthKey = (year: number, monthIndex: number) =>
|
|
1088
|
+
`${year}-${String(monthIndex + 1).padStart(2, "0")}`;
|
|
1089
|
+
|
|
1090
|
+
const yearOf = (monthKeyValue: string) => Number(monthKeyValue.slice(0, 4));
|
|
1091
|
+
|
|
1092
|
+
/** Newest selected month's year, falling back to the range's last year. */
|
|
1093
|
+
function startingYear(selected: string[], fallbackYear: number): number {
|
|
1094
|
+
if (selected.length === 0) return fallbackYear;
|
|
1095
|
+
const newest = [...selected].sort().reverse()[0];
|
|
1096
|
+
const parsed = yearOf(newest);
|
|
1097
|
+
return Number.isNaN(parsed) ? fallbackYear : parsed;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Month RANGE chip: pick a start month and an end month on the same year grid.
|
|
1102
|
+
*
|
|
1103
|
+
* Distinct from MonthFilterChip below, which ticks an arbitrary SET of months.
|
|
1104
|
+
* A range is the right shape when the value is going into a request rather than
|
|
1105
|
+
* being matched client-side — a start/end pair is what a "from month, to month"
|
|
1106
|
+
* endpoint takes, and a set of months is not expressible in one.
|
|
1107
|
+
*
|
|
1108
|
+
* The value is never empty: a caller sending it to an API always has some window
|
|
1109
|
+
* in force, so "Reset" restores `defaultRange` rather than clearing to nothing,
|
|
1110
|
+
* and the chip renders the range it is on at all times. That is deliberate — a
|
|
1111
|
+
* filter that silently governs a request should say what it is set to, not read
|
|
1112
|
+
* as unset while quietly bounding every row on screen.
|
|
1113
|
+
*
|
|
1114
|
+
* Clicking cycles the way a date-range picker does: the first click starts a new
|
|
1115
|
+
* range, the second closes it, and a click before the open start moves the start
|
|
1116
|
+
* instead of making a backwards range.
|
|
1117
|
+
*/
|
|
1118
|
+
export function MonthRangeFilterChip({
|
|
1119
|
+
chipKey,
|
|
1120
|
+
label = "Period",
|
|
1121
|
+
bounds,
|
|
1122
|
+
value,
|
|
1123
|
+
defaultRange,
|
|
1124
|
+
monthsWithData,
|
|
1125
|
+
onChange,
|
|
1126
|
+
}: {
|
|
1127
|
+
chipKey?: string;
|
|
1128
|
+
label?: string;
|
|
1129
|
+
/** The outer limits the grid lets the merchant navigate and pick within. */
|
|
1130
|
+
bounds: MonthRange;
|
|
1131
|
+
/** The range currently in force. Always set — see the note above. */
|
|
1132
|
+
value: MonthRange;
|
|
1133
|
+
/** What Reset goes back to, typically the window the page opens on. */
|
|
1134
|
+
defaultRange: MonthRange;
|
|
1135
|
+
/** Months with a row behind them, as "YYYY-MM". Drives the grid's dots. */
|
|
1136
|
+
monthsWithData: Set<string>;
|
|
1137
|
+
onChange: (next: MonthRange) => void;
|
|
1138
|
+
}) {
|
|
1139
|
+
const chip = useFilterChipState(chipKey ?? label);
|
|
1140
|
+
const minYear = yearOf(bounds.start);
|
|
1141
|
+
const maxYear = yearOf(bounds.end);
|
|
1142
|
+
|
|
1143
|
+
const [draft, setDraft] = useState<MonthRange>(value);
|
|
1144
|
+
/** Set once a start has been picked and the end is still open, so the next
|
|
1145
|
+
* click closes the range instead of starting another one. */
|
|
1146
|
+
const [awaitingEnd, setAwaitingEnd] = useState(false);
|
|
1147
|
+
const [year, setYear] = useState(() => yearOf(value.end) || maxYear);
|
|
1148
|
+
|
|
1149
|
+
// Always active: there is always a window in force.
|
|
1150
|
+
const isDefault = value.start === defaultRange.start && value.end === defaultRange.end;
|
|
1151
|
+
|
|
1152
|
+
const pick = (month: string) => {
|
|
1153
|
+
if (!awaitingEnd) {
|
|
1154
|
+
setDraft({ start: month, end: month });
|
|
1155
|
+
setAwaitingEnd(true);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
// A click before the open start moves the start rather than inverting the
|
|
1159
|
+
// range, which is what every date-range picker does and what a merchant
|
|
1160
|
+
// correcting an over-shot first click means.
|
|
1161
|
+
setDraft((prev) =>
|
|
1162
|
+
month < prev.start ? { start: month, end: prev.end } : { start: prev.start, end: month }
|
|
1163
|
+
);
|
|
1164
|
+
setAwaitingEnd(false);
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
const reset = () => {
|
|
1168
|
+
setDraft(defaultRange);
|
|
1169
|
+
setAwaitingEnd(false);
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const summary = `${formatMonthLabel(draft.start)} – ${formatMonthLabel(draft.end)}`;
|
|
1173
|
+
|
|
1174
|
+
return (
|
|
1175
|
+
<Popover
|
|
1176
|
+
open={chip.open}
|
|
1177
|
+
onOpenChange={(next) => {
|
|
1178
|
+
chip.onOpenChange(next);
|
|
1179
|
+
if (next) {
|
|
1180
|
+
setDraft(value);
|
|
1181
|
+
setAwaitingEnd(false);
|
|
1182
|
+
setYear(yearOf(value.end) || maxYear);
|
|
1183
|
+
}
|
|
1184
|
+
}}
|
|
1185
|
+
>
|
|
1186
|
+
<FilterChipShell active>
|
|
1187
|
+
<PopoverTrigger asChild>
|
|
1188
|
+
{/* The chip carries the range itself, not just the word "Period":
|
|
1189
|
+
this value is in the request body, so the merchant should be able
|
|
1190
|
+
to read what the table is bounded by without opening anything. */}
|
|
1191
|
+
<FilterChipLabelTrigger
|
|
1192
|
+
label={`${label}: ${formatMonthLabel(value.start)} – ${formatMonthLabel(value.end)}`}
|
|
1193
|
+
active
|
|
1194
|
+
/>
|
|
1195
|
+
</PopoverTrigger>
|
|
1196
|
+
</FilterChipShell>
|
|
1197
|
+
<PopoverContent align="end" className="w-60 p-3" onCloseAutoFocus={chip.onCloseAutoFocus}>
|
|
1198
|
+
<div className="flex items-center justify-between">
|
|
1199
|
+
<IconButton
|
|
1200
|
+
aria-label="Previous year"
|
|
1201
|
+
variant="ghost"
|
|
1202
|
+
size="xs"
|
|
1203
|
+
disabled={year <= minYear}
|
|
1204
|
+
onClick={() => setYear((prev) => prev - 1)}
|
|
1205
|
+
>
|
|
1206
|
+
<ChevronLeft className="h-3.5 w-3.5" />
|
|
1207
|
+
</IconButton>
|
|
1208
|
+
<span className="text-[12.5px] font-semibold text-foreground">{year}</span>
|
|
1209
|
+
<IconButton
|
|
1210
|
+
aria-label="Next year"
|
|
1211
|
+
variant="ghost"
|
|
1212
|
+
size="xs"
|
|
1213
|
+
disabled={year >= maxYear}
|
|
1214
|
+
onClick={() => setYear((prev) => prev + 1)}
|
|
1215
|
+
>
|
|
1216
|
+
<ChevronRight className="h-3.5 w-3.5" />
|
|
1217
|
+
</IconButton>
|
|
1218
|
+
</div>
|
|
1219
|
+
|
|
1220
|
+
<div className="mt-2 grid grid-cols-4 gap-1">
|
|
1221
|
+
{MONTHS_SHORT.map((monthLabel, index) => {
|
|
1222
|
+
const monthValue = monthKey(year, index);
|
|
1223
|
+
// Plain string comparison: "YYYY-MM" sorts chronologically.
|
|
1224
|
+
const inBounds = monthValue >= bounds.start && monthValue <= bounds.end;
|
|
1225
|
+
const isEdge = monthValue === draft.start || monthValue === draft.end;
|
|
1226
|
+
const isBetween = monthValue > draft.start && monthValue < draft.end;
|
|
1227
|
+
const hasData = monthsWithData.has(monthValue);
|
|
1228
|
+
|
|
1229
|
+
return (
|
|
1230
|
+
<Button
|
|
1231
|
+
key={monthValue}
|
|
1232
|
+
type="button"
|
|
1233
|
+
variant={isEdge ? "primary" : "ghost"}
|
|
1234
|
+
size="sm"
|
|
1235
|
+
disabled={!inBounds}
|
|
1236
|
+
aria-pressed={isEdge || isBetween}
|
|
1237
|
+
aria-label={`${monthLabel} ${year}${hasData ? ", has receipts" : ""}`}
|
|
1238
|
+
onClick={() => pick(monthValue)}
|
|
1239
|
+
className={cn(
|
|
1240
|
+
"relative h-auto min-h-0 w-full justify-center rounded-md px-0 pb-2.5 pt-1.5 text-[12px]",
|
|
1241
|
+
!isEdge && "text-foreground hover:bg-muted/60",
|
|
1242
|
+
isBetween && "bg-primary/15 text-primary"
|
|
1243
|
+
)}
|
|
1244
|
+
>
|
|
1245
|
+
{monthLabel}
|
|
1246
|
+
{hasData && (
|
|
1247
|
+
<span
|
|
1248
|
+
aria-hidden
|
|
1249
|
+
className={cn(
|
|
1250
|
+
"absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full",
|
|
1251
|
+
isEdge ? "bg-primary-foreground" : "bg-primary"
|
|
1252
|
+
)}
|
|
1253
|
+
/>
|
|
1254
|
+
)}
|
|
1255
|
+
</Button>
|
|
1256
|
+
);
|
|
1257
|
+
})}
|
|
1258
|
+
</div>
|
|
1259
|
+
|
|
1260
|
+
<p className="mt-2 truncate text-[11px] text-muted-foreground" title={summary}>
|
|
1261
|
+
{awaitingEnd ? `${formatMonthLabel(draft.start)} – pick an end month` : summary}
|
|
1262
|
+
</p>
|
|
1263
|
+
|
|
1264
|
+
<Separator className="my-2" />
|
|
1265
|
+
|
|
1266
|
+
<div className="flex items-center justify-between gap-2">
|
|
1267
|
+
<Button
|
|
1268
|
+
variant="ghost"
|
|
1269
|
+
size="sm"
|
|
1270
|
+
leftIcon={<X className="h-3 w-3" />}
|
|
1271
|
+
onClick={reset}
|
|
1272
|
+
disabled={isDefault && draft.start === defaultRange.start && draft.end === defaultRange.end}
|
|
1273
|
+
className="text-muted-foreground hover:text-foreground"
|
|
1274
|
+
>
|
|
1275
|
+
Reset
|
|
1276
|
+
</Button>
|
|
1277
|
+
<Button
|
|
1278
|
+
variant="primary"
|
|
1279
|
+
size="sm"
|
|
1280
|
+
onClick={() => {
|
|
1281
|
+
onChange(draft);
|
|
1282
|
+
chip.onOpenChange(false);
|
|
1283
|
+
}}
|
|
1284
|
+
>
|
|
1285
|
+
Apply
|
|
1286
|
+
</Button>
|
|
1287
|
+
</div>
|
|
1288
|
+
</PopoverContent>
|
|
1289
|
+
</Popover>
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ── Text chip ─────────────────────────────────────────────────────────────────
|
|
1294
|
+
|
|
1295
|
+
export interface TextFilterChipProps extends FilterChipControl {
|
|
1296
|
+
chipKey?: string;
|
|
1297
|
+
label?: string;
|
|
1298
|
+
value: string;
|
|
1299
|
+
onChange: (next: string) => void;
|
|
1300
|
+
/** Field label inside the panel. Defaults to `<label> contains`. */
|
|
1301
|
+
fieldLabel?: string;
|
|
1302
|
+
placeholder?: string;
|
|
1303
|
+
/** One line under the field — what the match actually does, typically. */
|
|
1304
|
+
hint?: string;
|
|
1305
|
+
/** Soft keyboard hint on touch devices. */
|
|
1306
|
+
inputMode?: "text" | "email" | "tel" | "numeric" | "url" | "search";
|
|
1307
|
+
align?: "start" | "center" | "end";
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* One free-text value, staged behind Apply.
|
|
1312
|
+
*
|
|
1313
|
+
* Deliberately not a live-filtering input: this chip sits in a toolbar whose
|
|
1314
|
+
* other chips all commit on Apply, and a field that filtered as you typed would
|
|
1315
|
+
* be the one control on the row that behaves differently. Enter applies, so it
|
|
1316
|
+
* still costs one keystroke.
|
|
1317
|
+
*
|
|
1318
|
+
* The applied value is trimmed — a trailing space pasted in with an address is
|
|
1319
|
+
* not something the user meant to search for.
|
|
1320
|
+
*/
|
|
1321
|
+
export function TextFilterChip({
|
|
1322
|
+
chipKey,
|
|
1323
|
+
label = "Text",
|
|
1324
|
+
value,
|
|
1325
|
+
onChange,
|
|
1326
|
+
fieldLabel,
|
|
1327
|
+
placeholder,
|
|
1328
|
+
hint,
|
|
1329
|
+
inputMode = "text",
|
|
1330
|
+
align = "start",
|
|
1331
|
+
open,
|
|
1332
|
+
onOpenChange,
|
|
1333
|
+
}: TextFilterChipProps) {
|
|
1334
|
+
const key = chipKey ?? label;
|
|
1335
|
+
const chip = useFilterChipState(key, { open, onOpenChange });
|
|
1336
|
+
const [draft, setDraft] = useState(value);
|
|
1337
|
+
|
|
1338
|
+
const isActive = !!value.trim();
|
|
1339
|
+
const apply = () => {
|
|
1340
|
+
onChange(draft.trim());
|
|
1341
|
+
chip.onOpenChange(false);
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
return (
|
|
1345
|
+
<FilterChip
|
|
1346
|
+
chipKey={key}
|
|
1347
|
+
// The chip carries the value it is filtering on, so the toolbar can be
|
|
1348
|
+
// read without opening anything.
|
|
1349
|
+
label={isActive ? `${label}: ${value.trim()}` : label}
|
|
1350
|
+
active={isActive}
|
|
1351
|
+
align={align}
|
|
1352
|
+
open={chip.open}
|
|
1353
|
+
onOpenChange={chip.onOpenChange}
|
|
1354
|
+
onOpen={() => setDraft(value)}
|
|
1355
|
+
onClear={() => {
|
|
1356
|
+
onChange("");
|
|
1357
|
+
setDraft("");
|
|
1358
|
+
}}
|
|
1359
|
+
>
|
|
1360
|
+
<div className="w-64 space-y-1.5 p-3">
|
|
1361
|
+
<p className="text-[11px] font-medium text-muted-foreground">
|
|
1362
|
+
{fieldLabel ?? `${label} contains`}
|
|
1363
|
+
</p>
|
|
1364
|
+
<Input
|
|
1365
|
+
type="text"
|
|
1366
|
+
inputMode={inputMode}
|
|
1367
|
+
autoComplete="off"
|
|
1368
|
+
placeholder={placeholder}
|
|
1369
|
+
value={draft}
|
|
1370
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
1371
|
+
onKeyDown={(e) => {
|
|
1372
|
+
if (e.key !== "Enter") return;
|
|
1373
|
+
e.preventDefault();
|
|
1374
|
+
apply();
|
|
1375
|
+
}}
|
|
1376
|
+
className="h-8 text-[12.5px]"
|
|
1377
|
+
/>
|
|
1378
|
+
{hint ? <p className="text-[11px] text-muted-foreground">{hint}</p> : null}
|
|
1379
|
+
</div>
|
|
1380
|
+
<FilterChipActions
|
|
1381
|
+
onClear={() => {
|
|
1382
|
+
onChange("");
|
|
1383
|
+
setDraft("");
|
|
1384
|
+
chip.onOpenChange(false);
|
|
1385
|
+
}}
|
|
1386
|
+
clearDisabled={!draft && !value}
|
|
1387
|
+
onApply={apply}
|
|
1388
|
+
/>
|
|
1389
|
+
</FilterChip>
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// ── Number-range chip ─────────────────────────────────────────────────────────
|
|
1394
|
+
|
|
1395
|
+
export interface NumberRangeValue {
|
|
1396
|
+
min: string;
|
|
1397
|
+
max: string;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
export interface NumberRangeFilterChipProps extends FilterChipControl {
|
|
1401
|
+
chipKey?: string;
|
|
1402
|
+
label?: string;
|
|
1403
|
+
value: NumberRangeValue;
|
|
1404
|
+
onChange: (next: NumberRangeValue) => void;
|
|
1405
|
+
/** Prefix inside each field — a currency symbol, typically. */
|
|
1406
|
+
prefix?: string;
|
|
1407
|
+
/** One line under the fields — what the bounds mean, or which field they match. */
|
|
1408
|
+
hint?: string;
|
|
1409
|
+
align?: "start" | "center" | "end";
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Min / Max, staged behind Apply. Unlike a date range, one end alone is a
|
|
1414
|
+
* perfectly ordinary request ("over ₹10,000"), so a half-filled range applies.
|
|
1415
|
+
*/
|
|
1416
|
+
export function NumberRangeFilterChip({
|
|
1417
|
+
chipKey = "amount",
|
|
1418
|
+
label = "Amount",
|
|
1419
|
+
value,
|
|
1420
|
+
onChange,
|
|
1421
|
+
prefix,
|
|
1422
|
+
hint,
|
|
1423
|
+
align = "start",
|
|
1424
|
+
open,
|
|
1425
|
+
onOpenChange,
|
|
1426
|
+
}: NumberRangeFilterChipProps) {
|
|
1427
|
+
const chip = useFilterChipState(chipKey, { open, onOpenChange });
|
|
1428
|
+
const [draft, setDraft] = useState<NumberRangeValue>(value);
|
|
1429
|
+
const isActive = !!(value.min || value.max);
|
|
1430
|
+
const inverted = !!draft.min && !!draft.max && Number(draft.min) > Number(draft.max);
|
|
1431
|
+
|
|
1432
|
+
return (
|
|
1433
|
+
<FilterChip
|
|
1434
|
+
chipKey={chipKey}
|
|
1435
|
+
label={label}
|
|
1436
|
+
active={isActive}
|
|
1437
|
+
align={align}
|
|
1438
|
+
open={chip.open}
|
|
1439
|
+
onOpenChange={chip.onOpenChange}
|
|
1440
|
+
onOpen={() => setDraft(value)}
|
|
1441
|
+
onClear={() => {
|
|
1442
|
+
onChange({ min: "", max: "" });
|
|
1443
|
+
setDraft({ min: "", max: "" });
|
|
1444
|
+
}}
|
|
1445
|
+
>
|
|
1446
|
+
<div className="w-56 space-y-3 p-3">
|
|
1447
|
+
{(["min", "max"] as const).map((end) => (
|
|
1448
|
+
<label key={end} className="block space-y-1">
|
|
1449
|
+
<span className="text-[11px] font-medium capitalize text-muted-foreground">{end}</span>
|
|
1450
|
+
<div className="relative">
|
|
1451
|
+
{prefix ? (
|
|
1452
|
+
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[12.5px] text-muted-foreground">
|
|
1453
|
+
{prefix}
|
|
1454
|
+
</span>
|
|
1455
|
+
) : null}
|
|
1456
|
+
<Input
|
|
1457
|
+
type="number"
|
|
1458
|
+
inputMode="decimal"
|
|
1459
|
+
value={draft[end]}
|
|
1460
|
+
onChange={(e) => setDraft((d) => ({ ...d, [end]: e.target.value }))}
|
|
1461
|
+
className={cn("h-8 text-[12.5px]", prefix && "pl-6")}
|
|
1462
|
+
/>
|
|
1463
|
+
</div>
|
|
1464
|
+
</label>
|
|
1465
|
+
))}
|
|
1466
|
+
{inverted ? (
|
|
1467
|
+
<p className="text-[11px] text-destructive">Min must not exceed max.</p>
|
|
1468
|
+
) : hint ? (
|
|
1469
|
+
<p className="text-[11px] text-muted-foreground">{hint}</p>
|
|
1470
|
+
) : null}
|
|
1471
|
+
</div>
|
|
1472
|
+
<FilterChipActions
|
|
1473
|
+
onClear={() => {
|
|
1474
|
+
const empty = { min: "", max: "" };
|
|
1475
|
+
onChange(empty);
|
|
1476
|
+
setDraft(empty);
|
|
1477
|
+
chip.onOpenChange(false);
|
|
1478
|
+
}}
|
|
1479
|
+
clearDisabled={!draft.min && !draft.max && !value.min && !value.max}
|
|
1480
|
+
applyDisabled={inverted}
|
|
1481
|
+
onApply={() => {
|
|
1482
|
+
onChange(draft);
|
|
1483
|
+
chip.onOpenChange(false);
|
|
1484
|
+
}}
|
|
1485
|
+
/>
|
|
1486
|
+
</FilterChip>
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// ── Add-filter menu ───────────────────────────────────────────────────────────
|
|
1491
|
+
|
|
1492
|
+
export interface AddFilterDefinition {
|
|
1493
|
+
key: string;
|
|
1494
|
+
label: string;
|
|
1495
|
+
/**
|
|
1496
|
+
* The values this filter accepts, so the search can match them directly.
|
|
1497
|
+
* Omit for a filter whose values are not a list — a date or amount range —
|
|
1498
|
+
* and it will still be findable by name.
|
|
1499
|
+
*/
|
|
1500
|
+
options?: FilterChipOption[];
|
|
1501
|
+
/** How many values are currently applied. Drives the count beside the name. */
|
|
1502
|
+
activeCount?: number;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
export interface AddFilterMenuProps extends FilterChipControl {
|
|
1506
|
+
chipKey?: string;
|
|
1507
|
+
/** Every filter this toolbar can offer, including ones already shown. */
|
|
1508
|
+
filters: AddFilterDefinition[];
|
|
1509
|
+
/** Keys already on screen as their own chip. */
|
|
1510
|
+
visibleKeys?: string[];
|
|
1511
|
+
/** Reveal a filter as its own chip. */
|
|
1512
|
+
onAddFilter: (key: string) => void;
|
|
1513
|
+
/**
|
|
1514
|
+
* Take a filter back out of the toolbar. Omit and a shown filter is simply
|
|
1515
|
+
* marked as shown; supply it and the row becomes a toggle.
|
|
1516
|
+
*
|
|
1517
|
+
* Removing must also clear whatever that filter had selected — a filter that
|
|
1518
|
+
* is still narrowing the table from somewhere the user cannot see it is worse
|
|
1519
|
+
* than one they have to scroll to.
|
|
1520
|
+
*/
|
|
1521
|
+
onRemoveFilter?: (key: string) => void;
|
|
1522
|
+
/** Apply a value picked straight out of the search results. */
|
|
1523
|
+
onSelectValue?: (filterKey: string, value: string) => void;
|
|
1524
|
+
label?: string;
|
|
1525
|
+
align?: "start" | "center" | "end";
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
type MenuRow =
|
|
1529
|
+
| { kind: "heading"; id: string; text: string }
|
|
1530
|
+
| { kind: "filter"; id: string; filter: AddFilterDefinition }
|
|
1531
|
+
| { kind: "value"; id: string; filter: AddFilterDefinition; option: FilterChipOption };
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* "Filter" — a searchable way to reach every filter a table has, instead of a
|
|
1535
|
+
* second-class drawer of leftovers.
|
|
1536
|
+
*
|
|
1537
|
+
* Two things it does that a nested accordion of checkbox groups does not.
|
|
1538
|
+
* Typing searches filter **names and their values at once**, so someone who
|
|
1539
|
+
* knows they want "USD" finds it without first knowing it lives under
|
|
1540
|
+
* Currency. And choosing anything here promotes that filter to a real chip in
|
|
1541
|
+
* the toolbar, so there is exactly one place a filter can be — beside its
|
|
1542
|
+
* peers — rather than some being chips and some being hidden rows.
|
|
1543
|
+
*
|
|
1544
|
+
* That also means the toolbar scales: a table with twenty filters shows the
|
|
1545
|
+
* three or four in use and keeps the rest one keystroke away.
|
|
1546
|
+
*/
|
|
1547
|
+
export function AddFilterMenu({
|
|
1548
|
+
chipKey = "__add-filter",
|
|
1549
|
+
filters,
|
|
1550
|
+
visibleKeys = [],
|
|
1551
|
+
onAddFilter,
|
|
1552
|
+
onRemoveFilter,
|
|
1553
|
+
onSelectValue,
|
|
1554
|
+
label = "Filter",
|
|
1555
|
+
align = "start",
|
|
1556
|
+
open: controlledOpen,
|
|
1557
|
+
onOpenChange: controlledOnOpenChange,
|
|
1558
|
+
}: AddFilterMenuProps) {
|
|
1559
|
+
const { open, onOpenChange, onCloseAutoFocus } = useFilterChipState(chipKey, {
|
|
1560
|
+
open: controlledOpen,
|
|
1561
|
+
onOpenChange: controlledOnOpenChange,
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
const [query, setQuery] = useState("");
|
|
1565
|
+
/** Raw highlight index; `cursor` below is this clamped to a real row. */
|
|
1566
|
+
const [storedCursor, setCursor] = useState(0);
|
|
1567
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
1568
|
+
const listId = useId();
|
|
1569
|
+
|
|
1570
|
+
const q = query.trim().toLowerCase();
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* The flat row list the keyboard walks. Headings are in it so the rendered
|
|
1574
|
+
* order and the keyboard order cannot drift; they are skipped when moving.
|
|
1575
|
+
*/
|
|
1576
|
+
const rows = useMemo<MenuRow[]>(() => {
|
|
1577
|
+
const out: MenuRow[] = [];
|
|
1578
|
+
|
|
1579
|
+
if (q) {
|
|
1580
|
+
// Values first: someone who typed "usd" wants the value, and having to
|
|
1581
|
+
// step past the field that contains it is the slower of the two orders.
|
|
1582
|
+
const valueMatches = filters.flatMap((f) =>
|
|
1583
|
+
(f.options ?? [])
|
|
1584
|
+
.filter((o) => o.label.toLowerCase().includes(q))
|
|
1585
|
+
.map((option) => ({ filter: f, option }))
|
|
1586
|
+
);
|
|
1587
|
+
if (valueMatches.length && onSelectValue) {
|
|
1588
|
+
out.push({ kind: "heading", id: "h-values", text: "Values" });
|
|
1589
|
+
for (const { filter, option } of valueMatches.slice(0, 20)) {
|
|
1590
|
+
out.push({
|
|
1591
|
+
kind: "value",
|
|
1592
|
+
id: `v-${filter.key}-${option.value}`,
|
|
1593
|
+
filter,
|
|
1594
|
+
option,
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const nameMatches = filters.filter((f) => f.label.toLowerCase().includes(q));
|
|
1600
|
+
if (nameMatches.length) {
|
|
1601
|
+
out.push({ kind: "heading", id: "h-fields", text: "Filters" });
|
|
1602
|
+
for (const filter of nameMatches) {
|
|
1603
|
+
out.push({ kind: "filter", id: `f-${filter.key}`, filter });
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
return out;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Resting state: every filter, with the ones already on screen marked so
|
|
1610
|
+
// the menu doubles as a map of what the toolbar is currently showing.
|
|
1611
|
+
out.push({
|
|
1612
|
+
kind: "heading",
|
|
1613
|
+
id: "h-all",
|
|
1614
|
+
text: onRemoveFilter ? "Show filters" : "All filters",
|
|
1615
|
+
});
|
|
1616
|
+
for (const filter of filters) {
|
|
1617
|
+
out.push({ kind: "filter", id: `f-${filter.key}`, filter });
|
|
1618
|
+
}
|
|
1619
|
+
return out;
|
|
1620
|
+
}, [filters, q, onSelectValue, onRemoveFilter]);
|
|
1621
|
+
|
|
1622
|
+
const selectable = useMemo(
|
|
1623
|
+
() => rows.map((r, i) => (r.kind === "heading" ? -1 : i)).filter((i) => i >= 0),
|
|
1624
|
+
[rows]
|
|
1625
|
+
);
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* The highlighted row, clamped to something that actually exists.
|
|
1629
|
+
*
|
|
1630
|
+
* Derived rather than corrected in an effect. A new query rebuilds `rows`, so
|
|
1631
|
+
* the stored index can now point at a heading, at a row that has gone, or
|
|
1632
|
+
* past the end — and an effect that reset it would have to depend on the
|
|
1633
|
+
* rebuilt array, which is a new identity on every parent render. That effect
|
|
1634
|
+
* would then fight the arrow keys, snapping the highlight back to the first
|
|
1635
|
+
* row each time the parent happened to re-render. Deriving it cannot.
|
|
1636
|
+
*/
|
|
1637
|
+
const cursor = selectable.includes(storedCursor) ? storedCursor : (selectable[0] ?? -1);
|
|
1638
|
+
|
|
1639
|
+
/** Keeps the highlighted row inside the scroll viewport. */
|
|
1640
|
+
useEffect(() => {
|
|
1641
|
+
if (cursor < 0) return;
|
|
1642
|
+
listRef.current
|
|
1643
|
+
?.querySelector<HTMLElement>(`[data-row-index="${cursor}"]`)
|
|
1644
|
+
?.scrollIntoView({ block: "nearest" });
|
|
1645
|
+
}, [cursor]);
|
|
1646
|
+
|
|
1647
|
+
const choose = (row: MenuRow) => {
|
|
1648
|
+
if (row.kind === "value") {
|
|
1649
|
+
onSelectValue?.(row.filter.key, row.option.value);
|
|
1650
|
+
// The filter it belongs to becomes a chip, so the applied value is
|
|
1651
|
+
// visible and removable in the same place as every other filter.
|
|
1652
|
+
onAddFilter(row.filter.key);
|
|
1653
|
+
// A value is a decision: close, so the chip it just created is visible.
|
|
1654
|
+
onOpenChange(false);
|
|
1655
|
+
setQuery("");
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
if (row.kind !== "filter") return;
|
|
1660
|
+
|
|
1661
|
+
// A filter row is a toggle, and the menu stays open for it — turning three
|
|
1662
|
+
// filters on is one visit, not three. Only picking a value closes.
|
|
1663
|
+
if (onRemoveFilter && visibleKeys.includes(row.filter.key)) {
|
|
1664
|
+
onRemoveFilter(row.filter.key);
|
|
1665
|
+
} else {
|
|
1666
|
+
onAddFilter(row.filter.key);
|
|
1667
|
+
if (!onRemoveFilter) {
|
|
1668
|
+
// Without a remove handler the row is not a toggle, so there is nothing
|
|
1669
|
+
// to come back for.
|
|
1670
|
+
onOpenChange(false);
|
|
1671
|
+
setQuery("");
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
const moveCursor = (delta: number) => {
|
|
1677
|
+
if (!selectable.length) return;
|
|
1678
|
+
const at = selectable.indexOf(cursor);
|
|
1679
|
+
const next = selectable[(at + delta + selectable.length) % selectable.length];
|
|
1680
|
+
setCursor(next);
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
1684
|
+
if (e.key === "ArrowDown") {
|
|
1685
|
+
e.preventDefault();
|
|
1686
|
+
moveCursor(1);
|
|
1687
|
+
} else if (e.key === "ArrowUp") {
|
|
1688
|
+
e.preventDefault();
|
|
1689
|
+
moveCursor(-1);
|
|
1690
|
+
} else if (e.key === "Enter") {
|
|
1691
|
+
const row = rows[cursor];
|
|
1692
|
+
if (!row || row.kind === "heading") return;
|
|
1693
|
+
e.preventDefault();
|
|
1694
|
+
choose(row);
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
const activeCount = filters.filter((f) => (f.activeCount ?? 0) > 0).length;
|
|
1699
|
+
|
|
1700
|
+
return (
|
|
1701
|
+
<Popover
|
|
1702
|
+
open={open}
|
|
1703
|
+
onOpenChange={(next) => {
|
|
1704
|
+
onOpenChange(next);
|
|
1705
|
+
if (next) setQuery("");
|
|
1706
|
+
}}
|
|
1707
|
+
>
|
|
1708
|
+
<FilterChipShell active={false}>
|
|
1709
|
+
<PopoverTrigger asChild>
|
|
1710
|
+
<FilterChipLabelTrigger
|
|
1711
|
+
label={label}
|
|
1712
|
+
active={false}
|
|
1713
|
+
rightIcon={<SlidersHorizontal className="h-3 w-3" />}
|
|
1714
|
+
aria-label={
|
|
1715
|
+
activeCount > 0 ? `${label}. ${activeCount} filters applied.` : label
|
|
1716
|
+
}
|
|
1717
|
+
/>
|
|
1718
|
+
</PopoverTrigger>
|
|
1719
|
+
</FilterChipShell>
|
|
1720
|
+
|
|
1721
|
+
<PopoverContent
|
|
1722
|
+
align={align}
|
|
1723
|
+
className={cn("w-72 p-0", POPOVER_FIT)}
|
|
1724
|
+
onKeyDown={onKeyDown}
|
|
1725
|
+
onCloseAutoFocus={onCloseAutoFocus}
|
|
1726
|
+
>
|
|
1727
|
+
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-3">
|
|
1728
|
+
<Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
1729
|
+
<input
|
|
1730
|
+
autoFocus
|
|
1731
|
+
value={query}
|
|
1732
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
1733
|
+
placeholder="Search filters and values"
|
|
1734
|
+
aria-controls={listId}
|
|
1735
|
+
aria-activedescendant={cursor >= 0 ? `${listId}-${cursor}` : undefined}
|
|
1736
|
+
className="h-full flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground"
|
|
1737
|
+
/>
|
|
1738
|
+
</div>
|
|
1739
|
+
|
|
1740
|
+
<div
|
|
1741
|
+
ref={listRef}
|
|
1742
|
+
id={listId}
|
|
1743
|
+
role="listbox"
|
|
1744
|
+
className={cn("max-h-80 p-1", FILTER_SCROLL_AREA)}
|
|
1745
|
+
>
|
|
1746
|
+
{rows.length === 0 ? (
|
|
1747
|
+
<p className="px-2 py-6 text-center text-[12.5px] text-muted-foreground">
|
|
1748
|
+
Nothing matches “{query}”.
|
|
1749
|
+
</p>
|
|
1750
|
+
) : (
|
|
1751
|
+
rows.map((row, i) =>
|
|
1752
|
+
row.kind === "heading" ? (
|
|
1753
|
+
<div
|
|
1754
|
+
key={row.id}
|
|
1755
|
+
className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
1756
|
+
>
|
|
1757
|
+
{row.text}
|
|
1758
|
+
</div>
|
|
1759
|
+
) : (
|
|
1760
|
+
<div
|
|
1761
|
+
key={row.id}
|
|
1762
|
+
id={`${listId}-${i}`}
|
|
1763
|
+
data-row-index={i}
|
|
1764
|
+
role="option"
|
|
1765
|
+
aria-selected={i === cursor}
|
|
1766
|
+
onMouseEnter={() => setCursor(i)}
|
|
1767
|
+
onClick={() => choose(row)}
|
|
1768
|
+
className={cn(
|
|
1769
|
+
"flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] text-foreground",
|
|
1770
|
+
i === cursor && "bg-muted"
|
|
1771
|
+
)}
|
|
1772
|
+
>
|
|
1773
|
+
{row.kind === "value" ? (
|
|
1774
|
+
<>
|
|
1775
|
+
{row.option.icon}
|
|
1776
|
+
<span className="min-w-0 flex-1 truncate">{row.option.label}</span>
|
|
1777
|
+
{/* Names the field the value belongs to: "USD" alone is
|
|
1778
|
+
ambiguous when two filters both take currencies. */}
|
|
1779
|
+
<span className="shrink-0 text-[11px] text-muted-foreground">
|
|
1780
|
+
{row.filter.label}
|
|
1781
|
+
</span>
|
|
1782
|
+
</>
|
|
1783
|
+
) : (
|
|
1784
|
+
<>
|
|
1785
|
+
<span className="min-w-0 flex-1 truncate">{row.filter.label}</span>
|
|
1786
|
+
{row.filter.activeCount ? (
|
|
1787
|
+
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 text-[10px] font-semibold tabular-nums text-primary">
|
|
1788
|
+
{row.filter.activeCount}
|
|
1789
|
+
</span>
|
|
1790
|
+
) : null}
|
|
1791
|
+
{visibleKeys.includes(row.filter.key) ? (
|
|
1792
|
+
<Check
|
|
1793
|
+
aria-label="Shown in the toolbar"
|
|
1794
|
+
className="h-3.5 w-3.5 shrink-0 text-primary"
|
|
1795
|
+
/>
|
|
1796
|
+
) : null}
|
|
1797
|
+
</>
|
|
1798
|
+
)}
|
|
1799
|
+
</div>
|
|
1800
|
+
)
|
|
1801
|
+
)
|
|
1802
|
+
)}
|
|
1803
|
+
</div>
|
|
1804
|
+
</PopoverContent>
|
|
1805
|
+
</Popover>
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* The row a table's filters live in: search at the left, chips beside it,
|
|
1813
|
+
* actions pinned right.
|
|
1814
|
+
*
|
|
1815
|
+
* Search and chips share one wrapping flex, so a chip that does not fit wraps
|
|
1816
|
+
* to the next line starting **under the search box** — a toolbar with three
|
|
1817
|
+
* filters is one line, one with eight grows a second, and nothing is ever
|
|
1818
|
+
* scrolled out of sight. Giving each group its own box instead would wrap the
|
|
1819
|
+
* chips inside their own column and leave a ragged left edge.
|
|
1820
|
+
*
|
|
1821
|
+
* Wraps its chips in a {@link FilterChipGroup}, so a toolbar built with it gets
|
|
1822
|
+
* the one-open-at-a-time behaviour without opting in.
|
|
1823
|
+
*/
|
|
1824
|
+
export function FilterToolbar({
|
|
1825
|
+
search,
|
|
1826
|
+
chips,
|
|
1827
|
+
actions,
|
|
1828
|
+
className,
|
|
1829
|
+
}: {
|
|
1830
|
+
search?: ReactNode;
|
|
1831
|
+
chips?: ReactNode;
|
|
1832
|
+
actions?: ReactNode;
|
|
1833
|
+
className?: string;
|
|
1834
|
+
}) {
|
|
1835
|
+
return (
|
|
1836
|
+
// `items-start` keeps the actions level with the FIRST line once the chips
|
|
1837
|
+
// wrap onto a second.
|
|
1838
|
+
<div className={cn("flex items-start gap-2", className)}>
|
|
1839
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
1840
|
+
{search}
|
|
1841
|
+
{/* `contents` so the group adds no box of its own: the chips become
|
|
1842
|
+
direct children of the wrapping flex and can break between lines. */}
|
|
1843
|
+
{chips ? <FilterChipGroup className="contents">{chips}</FilterChipGroup> : null}
|
|
1844
|
+
</div>
|
|
1845
|
+
{actions ? (
|
|
1846
|
+
<div className="flex min-h-8 shrink-0 items-center gap-2">{actions}</div>
|
|
1847
|
+
) : null}
|
|
1848
|
+
</div>
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* The pill action button that sits at the right of a table toolbar — Refresh,
|
|
1854
|
+
* Columns, Export, Report.
|
|
1855
|
+
*
|
|
1856
|
+
* It exists because `Button size="sm"` is `h-9`, which towers over the chips
|
|
1857
|
+
* beside it; every toolbar that wanted a level row was overriding the same four
|
|
1858
|
+
* classes by hand. Having it here means a toolbar's actions match its chips
|
|
1859
|
+
* without each one rediscovering that.
|
|
1860
|
+
*/
|
|
1861
|
+
export function ToolbarButton({ className, ...props }: ComponentPropsWithoutRef<typeof Button>) {
|
|
1862
|
+
return (
|
|
1863
|
+
<Button
|
|
1864
|
+
type="button"
|
|
1865
|
+
variant="outline"
|
|
1866
|
+
size="sm"
|
|
1867
|
+
className={cn(
|
|
1868
|
+
"h-auto min-h-0 shrink-0 py-1 text-muted-foreground hover:text-foreground",
|
|
1869
|
+
className
|
|
1870
|
+
)}
|
|
1871
|
+
{...props}
|
|
1872
|
+
/>
|
|
1873
|
+
);
|
|
1874
|
+
}
|