@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.
- package/dist/index.cjs +3145 -592
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1206 -26
- package/dist/index.d.ts +1206 -26
- package/dist/index.js +3094 -546
- package/dist/index.js.map +1 -1
- package/package.json +14 -4
- package/src/__tests__/data-card-list.test.tsx +74 -0
- package/src/__tests__/data-table-row-click.test.tsx +113 -0
- package/src/__tests__/filter-chips.test.tsx +237 -0
- package/src/calendar-date-chip.tsx +262 -0
- package/src/column-manager.tsx +590 -0
- package/src/copyable-cell.tsx +253 -0
- package/src/data-card-list.tsx +188 -0
- package/src/data-table-card.tsx +205 -0
- package/src/data-table.tsx +876 -144
- package/src/date-picker.tsx +15 -3
- package/src/filter-chips.tsx +1874 -0
- package/src/format-datetime.ts +170 -0
- package/src/index.ts +95 -2
- package/src/popover.tsx +24 -15
- package/src/rotating-search-input.tsx +164 -0
- package/src/tab-presets.tsx +189 -0
|
@@ -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 {
|
|
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
|
-
>(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|