@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,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
+ }