@payglocal_ui/flux-ui 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The app-wide date and time format.
3
+ *
4
+ * Every timestamp a PayGlocal dashboard shows a user goes through here, so a
5
+ * transaction row, a settlement detail page, an audit log line and a chart
6
+ * tooltip all read the same: `27 Jul '26, 09:49 AM`.
7
+ *
8
+ * Nothing here goes through `toLocaleDateString` / `toLocaleTimeString`. That
9
+ * is deliberate: Intl output varies with the machine's locale, so the same
10
+ * record would read differently for an operator in Bengaluru and a merchant in
11
+ * Frankfurt, and a screenshot in a support ticket would not match what the
12
+ * agent sees. These build the string from fixed tables instead.
13
+ *
14
+ * Times are rendered in the **viewer's own timezone**, which is what every
15
+ * `Date` getter below returns. That is the right default for an operations
16
+ * console — "did this settle before close of business *here*" is the question
17
+ * being asked — but it does mean two people in different zones see different
18
+ * clock times for one event, so anywhere that matters should label the zone.
19
+ */
20
+
21
+ const MONTHS_SHORT = [
22
+ "Jan",
23
+ "Feb",
24
+ "Mar",
25
+ "Apr",
26
+ "May",
27
+ "Jun",
28
+ "Jul",
29
+ "Aug",
30
+ "Sep",
31
+ "Oct",
32
+ "Nov",
33
+ "Dec",
34
+ ] as const;
35
+
36
+ const DAYS_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
37
+
38
+ /** What an absent or unparseable value renders as, everywhere. */
39
+ export const EMPTY_DATE = "—";
40
+
41
+ /**
42
+ * Parses the shapes PayGlocal APIs actually send, in the order they are most
43
+ * likely to appear:
44
+ *
45
+ * - `DD/MM/YYYY HH:mm:ss` — the transactions search response's
46
+ * `formattedCreationDateTime`. Tried **first**, because `new Date()` reads
47
+ * `03/07/2026` as *March 7th* under US parsing rules, silently swapping the
48
+ * day and month for the first twelve days of every month.
49
+ * - epoch milliseconds, as a number **or a string** — several endpoints send
50
+ * `"1771329858260"`. The string form needs `Number()` first: the `Date`
51
+ * constructor reads a string as a date *format*, not a count of
52
+ * milliseconds, so `new Date("1771329858260")` is an Invalid Date.
53
+ * - ISO 8601 — `settlementDate`, and most newer endpoints.
54
+ */
55
+ export function parseApiDate(value: string | number | Date | null | undefined): Date | null {
56
+ if (value === null || value === undefined || value === "") return null;
57
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
58
+
59
+ if (typeof value === "number") {
60
+ const fromMillis = new Date(value);
61
+ return Number.isNaN(fromMillis.getTime()) ? null : fromMillis;
62
+ }
63
+
64
+ const raw = value.trim();
65
+
66
+ const slashed = raw.match(
67
+ /^(\d{2})\/(\d{2})\/(\d{4})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
68
+ );
69
+ if (slashed) {
70
+ const [, dd, mm, yyyy, hh = "0", min = "0", ss = "0"] = slashed;
71
+ const parsed = new Date(
72
+ Number(yyyy),
73
+ Number(mm) - 1,
74
+ Number(dd),
75
+ Number(hh),
76
+ Number(min),
77
+ Number(ss)
78
+ );
79
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
80
+ }
81
+
82
+ // A string of digits is epoch millis, not a date format.
83
+ if (/^\d+$/.test(raw)) {
84
+ const parsed = new Date(Number(raw));
85
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
86
+ }
87
+
88
+ const parsed = new Date(raw);
89
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
90
+ }
91
+
92
+ /** `09:49 AM` — 12-hour, zero-padded, uppercase meridiem. */
93
+ export function formatTime(date: Date): string {
94
+ const hours24 = date.getHours();
95
+ const hours12 = hours24 % 12 || 12;
96
+ const meridiem = hours24 >= 12 ? "PM" : "AM";
97
+ return `${String(hours12).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")} ${meridiem}`;
98
+ }
99
+
100
+ /** `27 Jul '26` — the date half, on its own. */
101
+ export function formatDateOnly(date: Date): string {
102
+ const yy = String(date.getFullYear() % 100).padStart(2, "0");
103
+ return `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]} '${yy}`;
104
+ }
105
+
106
+ /** `27 Jul '26, 09:49 AM` — the canonical form. */
107
+ export function formatDateTime(date: Date): string {
108
+ return `${formatDateOnly(date)}, ${formatTime(date)}`;
109
+ }
110
+
111
+ /**
112
+ * Any API value → `27 Jul '26, 09:49 AM`.
113
+ *
114
+ * This is the one to reach for in a column renderer or a detail field: it takes
115
+ * whatever shape the endpoint sends, and returns the em dash rather than
116
+ * "Invalid Date" when there is nothing to show.
117
+ *
118
+ * `fallback` is what an absent or unparseable value renders as. It defaults to
119
+ * the em dash; pass `""` where the timestamp sits inside a sentence that should
120
+ * simply omit it rather than show a placeholder.
121
+ */
122
+ export function formatTimestamp(
123
+ value: string | number | Date | null | undefined,
124
+ fallback: string = EMPTY_DATE
125
+ ): string {
126
+ const date = parseApiDate(value);
127
+ return date ? formatDateTime(date) : fallback;
128
+ }
129
+
130
+ /** Any API value → `27 Jul '26`, with no time of day. */
131
+ export function formatDateStamp(
132
+ value: string | number | Date | null | undefined,
133
+ fallback: string = EMPTY_DATE
134
+ ): string {
135
+ const date = parseApiDate(value);
136
+ return date ? formatDateOnly(date) : fallback;
137
+ }
138
+
139
+ /** Any API value → `09:49 AM`, with no date. */
140
+ export function formatTimeStamp(
141
+ value: string | number | Date | null | undefined,
142
+ fallback: string = EMPTY_DATE
143
+ ): string {
144
+ const date = parseApiDate(value);
145
+ return date ? formatTime(date) : fallback;
146
+ }
147
+
148
+ /**
149
+ * `Mon, 27 Jul` — weekday and date, no year. For a date close enough to the
150
+ * present that naming the day of the week reads better than a bare calendar
151
+ * date, such as a "next settlement" line.
152
+ */
153
+ export function formatWeekdayDate(
154
+ value: string | number | Date | null | undefined,
155
+ fallback: string = EMPTY_DATE
156
+ ): string {
157
+ const date = parseApiDate(value);
158
+ if (!date) return fallback;
159
+ return `${DAYS_SHORT[date.getDay()]}, ${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`;
160
+ }
161
+
162
+ /** `Jan 2026` — a month key (`YYYY-MM`) as a label. */
163
+ export function formatMonthLabel(monthKey: string): string {
164
+ const [year, month] = monthKey.split("-");
165
+ const name = MONTHS_SHORT[Number(month) - 1];
166
+ if (!year || !name) return monthKey;
167
+ return `${name} ${year}`;
168
+ }
169
+
170
+ export { MONTHS_SHORT, DAYS_SHORT };
package/src/index.ts CHANGED
@@ -209,6 +209,8 @@ export type {
209
209
 
210
210
  // Tabs & Accordion
211
211
  export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
212
+ export { UnderlineTabs, SegmentedTabs } from "./tab-presets";
213
+ export type { UnderlineTab, SegmentedTabOption } from "./tab-presets";
212
214
  export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./accordion";
213
215
 
214
216
  // Messaging & Feedback
@@ -232,8 +234,34 @@ export type { ProgressProps, ProgressTrackerStep, ProgressTrackerProps } from ".
232
234
  export { Shimmer, StatCardSkeleton, TableRowSkeleton, ChartSkeleton } from "./skeleton";
233
235
 
234
236
  // Data display
235
- export { DataTable } from "./data-table";
236
- export type { Column, DataTableDensity, DataTableExpandable, DataTableFooterSummary, DataTableHeaderStyle } from "./data-table";
237
+ export { DataTable, frozenColumn } from "./data-table";
238
+ export type {
239
+ Column,
240
+ DataTableDensity,
241
+ DataTableExpandable,
242
+ DataTableFooterSummary,
243
+ DataTableHeaderStyle,
244
+ DataTablePagination,
245
+ DataTableSorting,
246
+ DataTableSortState,
247
+ SortOrder,
248
+ } from "./data-table";
249
+ export { DataTableCard, TableToolbarActions } from "./data-table-card";
250
+ export { DataCardList } from "./data-card-list";
251
+ export type { DataCardListProps } from "./data-card-list";
252
+ export { CopyableCell } from "./copyable-cell";
253
+ export type { CopyableCellProps } from "./copyable-cell";
254
+ export { RotatingSearchInput } from "./rotating-search-input";
255
+ export type { RotatingSearchInputProps } from "./rotating-search-input";
256
+ export type { DataTableCardProps } from "./data-table-card";
257
+ export { ColumnManager, useColumnPreferences, applyColumnPreferences } from "./column-manager";
258
+ export type {
259
+ ColumnManagerProps,
260
+ ColumnPreferences,
261
+ ManagedColumn,
262
+ UseColumnPreferencesOptions,
263
+ UseColumnPreferencesResult,
264
+ } from "./column-manager";
237
265
  export { EmptyState } from "./empty-state";
238
266
  export { PageHeader } from "./page-header";
239
267
  export { Code, CodeBlock } from "./code";
@@ -331,3 +359,68 @@ export type { ProgressIndicatorProps } from "./progress-indicator";
331
359
  // Utility
332
360
  export { VisuallyHidden } from "./visually-hidden";
333
361
  export type { VisuallyHiddenProps } from "./visually-hidden";
362
+
363
+ // Filters
364
+ export {
365
+ FilterChipGroup,
366
+ useFilterChipState,
367
+ FilterChipShell,
368
+ FilterChipClearButton,
369
+ FilterChipLabelTrigger,
370
+ FilterChipActions,
371
+ FilterChip,
372
+ SelectFilterChip,
373
+ SingleSelectFilterChip,
374
+ DateRangeFilterChip,
375
+ NumberRangeFilterChip,
376
+ TextFilterChip,
377
+ MonthRangeFilterChip,
378
+ EMPTY_RELATIVE_RANGE,
379
+ hasRelativeRange,
380
+ relativeRangeToMillis,
381
+ AddFilterMenu,
382
+ FilterToolbar,
383
+ ToolbarButton,
384
+ } from "./filter-chips";
385
+
386
+ // The calendar-based date chip, for a filter people reach for by looking
387
+ // rather than by typing two dates.
388
+ export { CalendarDateFilterChip } from "./calendar-date-chip";
389
+ export type {
390
+ CalendarDateValue,
391
+ CalendarDatePreset,
392
+ CalendarDateFilterChipProps,
393
+ CalendarRange,
394
+ DatePickMode,
395
+ } from "./calendar-date-chip";
396
+ export type {
397
+ FilterChipOption,
398
+ FilterChipControl,
399
+ SelectFilterChipProps,
400
+ SingleSelectFilterChipProps,
401
+ DateRangeValue,
402
+ DateRangeFilterChipProps,
403
+ NumberRangeValue,
404
+ NumberRangeFilterChipProps,
405
+ TextFilterChipProps,
406
+ RelativeRangeValue,
407
+ MonthRange,
408
+ AddFilterDefinition,
409
+ AddFilterMenuProps,
410
+ } from "./filter-chips";
411
+
412
+ // Date & time formatting (the app-wide "27 Jul '26, 09:49 AM" form)
413
+ export {
414
+ parseApiDate,
415
+ formatTime,
416
+ formatDateOnly,
417
+ formatDateTime,
418
+ formatTimestamp,
419
+ formatDateStamp,
420
+ formatMonthLabel,
421
+ formatTimeStamp,
422
+ formatWeekdayDate,
423
+ EMPTY_DATE,
424
+ MONTHS_SHORT,
425
+ DAYS_SHORT,
426
+ } from "./format-datetime";
package/src/popover.tsx CHANGED
@@ -11,21 +11,30 @@ const PopoverAnchor = PopoverPrimitive.Anchor;
11
11
  const PopoverContent = React.forwardRef<
12
12
  React.ElementRef<typeof PopoverPrimitive.Content>,
13
13
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
14
- >(({ className, align = "center", sideOffset = 6, ...props }, ref) => (
15
- <PopoverPrimitive.Portal>
16
- <PopoverPrimitive.Content
17
- ref={ref}
18
- align={align}
19
- sideOffset={sideOffset}
20
- className={cn(
21
- "z-[120] w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg outline-none",
22
- "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 transition-opacity duration-150",
23
- className
24
- )}
25
- {...props}
26
- />
27
- </PopoverPrimitive.Portal>
28
- ));
14
+ >(
15
+ (
16
+ { className, align = "center", sideOffset = 6, collisionPadding = 12, ...props },
17
+ ref
18
+ ) => (
19
+ <PopoverPrimitive.Portal>
20
+ <PopoverPrimitive.Content
21
+ ref={ref}
22
+ align={align}
23
+ sideOffset={sideOffset}
24
+ // Keep a gutter between the popover and the viewport edge, so a
25
+ // content-bounded panel (see `--radix-popover-content-available-height`
26
+ // below) stops short of the fold rather than sitting flush against it.
27
+ collisionPadding={collisionPadding}
28
+ className={cn(
29
+ "z-[120] w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg outline-none",
30
+ "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 transition-opacity duration-150",
31
+ className
32
+ )}
33
+ {...props}
34
+ />
35
+ </PopoverPrimitive.Portal>
36
+ )
37
+ );
29
38
  PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30
39
 
31
40
  export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
4
+ import { Search } from "lucide-react";
5
+ import { Input } from "./input";
6
+ import { cn } from "./utils";
7
+
8
+ /** Height of one word slot, in px. The carousel translates by multiples of it. */
9
+ const WORD_HEIGHT = 20;
10
+
11
+ export interface RotatingSearchInputProps {
12
+ /** Controlled value. Omit to let the field own it. */
13
+ value?: string;
14
+ /** Fires on the debounced value, not on every keystroke. */
15
+ onSearch: (value: string) => void;
16
+ /** The hints to cycle through: "Amount", "Transaction ID", "Email". */
17
+ words: string[];
18
+ /** Debounce before `onSearch` fires. Default 300ms. */
19
+ debounceDelay?: number;
20
+ className?: string;
21
+ /** Screen-reader name for the field. */
22
+ ariaLabel?: string;
23
+ }
24
+
25
+ /**
26
+ * The table search box: one field whose placeholder cycles through what it can
27
+ * actually match — "Search by Amount", then "Transaction ID", then "Email".
28
+ *
29
+ * That rotation is the whole point. A single grid search usually spans half a
30
+ * dozen fields, and a static "Search" placeholder tells the user none of them,
31
+ * so they guess at what is searchable and conclude the box is broken when their
32
+ * guess misses. Naming the fields in turn costs no space and answers it.
33
+ *
34
+ * `onSearch` is debounced, so a search that hits the network fires once the
35
+ * user pauses rather than once per keystroke.
36
+ */
37
+ export function RotatingSearchInput({
38
+ value,
39
+ onSearch,
40
+ words,
41
+ debounceDelay = 300,
42
+ className,
43
+ ariaLabel = "Search",
44
+ }: RotatingSearchInputProps) {
45
+ const [internalValue, setInternalValue] = useState(value ?? "");
46
+ const [index, setIndex] = useState(0);
47
+ const [withTransition, setWithTransition] = useState(true);
48
+
49
+ /**
50
+ * The latest handler, read at fire time. Without this the debounce closes
51
+ * over the handler it was created with, so a search firing after a re-render
52
+ * would call a stale one — and these handlers close over filter state.
53
+ */
54
+ const latestOnSearch = useRef(onSearch);
55
+ useEffect(() => {
56
+ latestOnSearch.current = onSearch;
57
+ }, [onSearch]);
58
+
59
+ const debouncedRef = useRef<{ (val: string): void; cancel(): void } | null>(null);
60
+ useEffect(() => {
61
+ let timeout: ReturnType<typeof setTimeout> | null = null;
62
+ const debounced = (val: string) => {
63
+ if (timeout) clearTimeout(timeout);
64
+ timeout = setTimeout(() => latestOnSearch.current(val), debounceDelay);
65
+ };
66
+ debounced.cancel = () => {
67
+ if (timeout) clearTimeout(timeout);
68
+ };
69
+ debouncedRef.current = debounced;
70
+ // Cancel on unmount, or a search fires into a component that is gone.
71
+ return () => debounced.cancel();
72
+ }, [debounceDelay]);
73
+
74
+ // Mirror a controlled value. Deferred a tick so a parent that resets the
75
+ // query while a debounce is in flight does not fight the field mid-keystroke.
76
+ useEffect(() => {
77
+ if (value === undefined) return;
78
+ const timer = setTimeout(() => setInternalValue(value), 0);
79
+ return () => clearTimeout(timer);
80
+ }, [value]);
81
+
82
+ /**
83
+ * The first word again at the end, so the last-to-first step slides forward
84
+ * like every other one instead of rewinding the whole list. The jump back to
85
+ * the real first word then happens with the transition off, where it cannot
86
+ * be seen.
87
+ */
88
+ const extendedWords = useMemo(
89
+ () => (words.length === 0 ? [] : [...words, words[0]]),
90
+ [words]
91
+ );
92
+
93
+ // A new word list restarts the carousel rather than leaving the index
94
+ // pointing into a list that no longer has that many entries.
95
+ useEffect(() => {
96
+ const timer = setTimeout(() => {
97
+ setWithTransition(false);
98
+ setIndex(0);
99
+ }, 0);
100
+ return () => clearTimeout(timer);
101
+ }, [words.length]);
102
+
103
+ useEffect(() => {
104
+ if (words.length === 0) return;
105
+ const id = setInterval(() => {
106
+ setWithTransition(true);
107
+ setIndex((prev) => prev + 1);
108
+ }, 2500);
109
+ return () => clearInterval(id);
110
+ }, [words.length]);
111
+
112
+ // Landed on the duplicated first word: let it rest, then snap back to the
113
+ // real one with the transition off.
114
+ useEffect(() => {
115
+ if (index !== words.length) return;
116
+ const timer = setTimeout(() => {
117
+ setWithTransition(false);
118
+ setIndex(0);
119
+ }, 2000);
120
+ return () => clearTimeout(timer);
121
+ }, [index, words.length]);
122
+
123
+ const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
124
+ setInternalValue(e.target.value);
125
+ debouncedRef.current?.(e.target.value);
126
+ };
127
+
128
+ return (
129
+ <div className={cn("relative", className)}>
130
+ {/* The rotating hint is an overlay, not the input's own `placeholder`,
131
+ because a placeholder cannot animate. It is pointer-events-none so it
132
+ never eats a click meant for the field, and it is dropped entirely
133
+ once there is a value to avoid sitting behind the user's text. */}
134
+ {!internalValue && words.length > 0 && (
135
+ <div className="pointer-events-none absolute left-8 top-1/2 z-10 flex h-5 -translate-y-1/2 items-start gap-1 overflow-hidden text-xs text-muted-foreground">
136
+ <span className="h-5 shrink-0 leading-5">Search by</span>
137
+ <div
138
+ style={{
139
+ transform: `translateY(-${index * WORD_HEIGHT}px)`,
140
+ transition: withTransition ? "transform 2s cubic-bezier(0.4, 0, 0.2, 1)" : "none",
141
+ }}
142
+ >
143
+ {extendedWords.map((word, i) => (
144
+ <div key={`${word}-${i}`} className="h-5 leading-5">
145
+ {word}
146
+ </div>
147
+ ))}
148
+ </div>
149
+ </div>
150
+ )}
151
+
152
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
153
+
154
+ <Input
155
+ type="text"
156
+ aria-label={ariaLabel}
157
+ value={internalValue}
158
+ placeholder=""
159
+ onChange={handleChange}
160
+ className="!h-8 min-h-0 bg-muted/50 pl-8 text-xs"
161
+ />
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,189 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
5
+ import { Tabs, TabsList, TabsTrigger } from "./tabs";
6
+ import { cn } from "./utils";
7
+
8
+ /**
9
+ * Two presets over the `Tabs` primitives, for the two jobs a tab row actually
10
+ * does. They are separate components on purpose, so a page can show both
11
+ * without the reader having to work out which row governs what:
12
+ *
13
+ * - {@link UnderlineTabs} is the **page-level** bar that segments a view into
14
+ * sections — full-width, one sliding indicator, the thing a URL usually
15
+ * follows.
16
+ * - {@link SegmentedTabs} is the **compact scoping strip** for a required
17
+ * single choice that qualifies the content beside it: which status a list is
18
+ * filtered by, which period a summary describes.
19
+ *
20
+ * Neither is a filter chip. A chip is for an *optional* filter that can be
21
+ * cleared; both of these always have exactly one option selected, so neither
22
+ * ever renders a clear affordance.
23
+ */
24
+
25
+ export interface UnderlineTab {
26
+ value: string;
27
+ /**
28
+ * A plain string for an ordinary tab; a node when the tab carries an
29
+ * annotation beside its name, such as a status badge showing the state of
30
+ * the section behind it.
31
+ */
32
+ label: ReactNode;
33
+ }
34
+
35
+ /**
36
+ * Page-level tab bar with a single shared indicator that slides between tabs,
37
+ * rather than each tab drawing its own underline.
38
+ *
39
+ * The indicator's position and width are measured from the DOM: text tabs have
40
+ * different widths, so this cannot be derived from props or state. It sits
41
+ * flush on the row's own bottom border instead of floating below the label.
42
+ *
43
+ * `actions` renders flush right on the same row, tabs staying left-aligned,
44
+ * which is where a page puts its primary CTA.
45
+ */
46
+ export function UnderlineTabs({
47
+ tabs,
48
+ value,
49
+ onValueChange,
50
+ actions,
51
+ className,
52
+ }: {
53
+ tabs: readonly UnderlineTab[];
54
+ value: string;
55
+ onValueChange: (value: string) => void;
56
+ actions?: ReactNode;
57
+ className?: string;
58
+ }) {
59
+ const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
60
+ const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null);
61
+
62
+ useEffect(() => {
63
+ const measure = () => {
64
+ const el = tabRefs.current[value];
65
+ if (el) setIndicator({ left: el.offsetLeft, width: el.offsetWidth });
66
+ };
67
+ // Deferred into a rAF / resize callback rather than called in the effect
68
+ // body: it reads post-layout geometry, which is not derivable from render.
69
+ const raf = requestAnimationFrame(measure);
70
+ window.addEventListener("resize", measure);
71
+ return () => {
72
+ cancelAnimationFrame(raf);
73
+ window.removeEventListener("resize", measure);
74
+ };
75
+ }, [value]);
76
+
77
+ return (
78
+ <Tabs value={value} onValueChange={onValueChange} className={className}>
79
+ <div className="flex flex-wrap items-center justify-between gap-3">
80
+ {/* `relative` here rather than on the row, so the indicator measures
81
+ against the tab strip itself and is unaffected by `actions`. */}
82
+ <TabsList className="relative h-auto justify-start gap-5 rounded-none border-0 bg-transparent p-0">
83
+ {tabs.map((tab) => (
84
+ <TabsTrigger
85
+ key={tab.value}
86
+ ref={(el) => {
87
+ tabRefs.current[tab.value] = el;
88
+ }}
89
+ value={tab.value}
90
+ className="h-auto rounded-none px-0 py-2.5 text-[13px] font-medium text-muted-foreground shadow-none data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none"
91
+ >
92
+ {tab.label}
93
+ </TabsTrigger>
94
+ ))}
95
+ <span
96
+ aria-hidden
97
+ className="absolute bottom-0 h-0.5 bg-primary transition-all duration-200 ease-out"
98
+ style={{
99
+ left: indicator?.left ?? 0,
100
+ width: indicator?.width ?? 0,
101
+ opacity: indicator ? 1 : 0,
102
+ }}
103
+ />
104
+ </TabsList>
105
+
106
+ {actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
107
+ </div>
108
+ </Tabs>
109
+ );
110
+ }
111
+
112
+ export interface SegmentedTabOption<T extends string = string> {
113
+ value: T;
114
+ label: string;
115
+ }
116
+
117
+ /**
118
+ * The compact strip that scopes the content beside it.
119
+ *
120
+ * Plain underlined triggers, not the `Tabs` pill look: no container
121
+ * background, border or padding — a gap row of triggers, each just an
122
+ * underline and a colour change when active. Still Radix `Tabs` underneath, so
123
+ * keyboard navigation and `aria-selected` come free; only the classes differ.
124
+ *
125
+ * With `collapseToSelect`, the strip becomes a `Select` below `md`. Both
126
+ * controls drive the same state, so resizing mid-session can never leave the
127
+ * two disagreeing about which option is chosen.
128
+ */
129
+ export function SegmentedTabs<T extends string>({
130
+ options,
131
+ value,
132
+ onValueChange,
133
+ label = "Options",
134
+ collapseToSelect = true,
135
+ className,
136
+ }: {
137
+ options: readonly SegmentedTabOption<T>[];
138
+ value: T;
139
+ onValueChange: (value: T) => void;
140
+ /** Accessible name for both controls. */
141
+ label?: string;
142
+ /**
143
+ * Swap to a `Select` below `md`. Leave on for a strip that would otherwise
144
+ * crowd a narrow screen; turn it off where the row already has the room and
145
+ * a dropdown would read as a different control appearing.
146
+ */
147
+ collapseToSelect?: boolean;
148
+ className?: string;
149
+ }) {
150
+ return (
151
+ <>
152
+ <Tabs
153
+ value={value}
154
+ onValueChange={(next) => onValueChange(next as T)}
155
+ className={cn(collapseToSelect && "hidden md:block", className)}
156
+ >
157
+ <TabsList
158
+ aria-label={label}
159
+ className="h-auto gap-4 rounded-none border-0 bg-transparent p-0"
160
+ >
161
+ {options.map((option) => (
162
+ <TabsTrigger
163
+ key={option.value}
164
+ value={option.value}
165
+ className="h-auto rounded-none border-b-2 border-transparent px-0 py-1 text-[13px] font-medium text-muted-foreground shadow-none data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none"
166
+ >
167
+ {option.label}
168
+ </TabsTrigger>
169
+ ))}
170
+ </TabsList>
171
+ </Tabs>
172
+
173
+ {collapseToSelect ? (
174
+ <Select value={value} onValueChange={(next) => onValueChange(next as T)}>
175
+ <SelectTrigger className="h-8 w-[9.5rem] md:hidden" aria-label={label}>
176
+ <SelectValue />
177
+ </SelectTrigger>
178
+ <SelectContent>
179
+ {options.map((option) => (
180
+ <SelectItem key={option.value} value={option.value}>
181
+ {option.label}
182
+ </SelectItem>
183
+ ))}
184
+ </SelectContent>
185
+ </Select>
186
+ ) : null}
187
+ </>
188
+ );
189
+ }