torch-glare 2.1.7 → 2.2.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/apps/lib/components/BadgeField.tsx +131 -12
- package/apps/lib/components/ContextMenu.tsx +524 -0
- package/apps/lib/components/DropdownMenu.tsx +254 -102
- package/apps/lib/components/SearchableSelect.tsx +308 -0
- package/apps/lib/components/SearchableTable.tsx +363 -0
- package/apps/lib/components/Table.tsx +6 -6
- package/docs/components/context-menu.md +455 -0
- package/docs/components/dropdown-menu.md +37 -34
- package/docs/components/searchable-select.md +359 -0
- package/docs/components/searchable-table.md +419 -0
- package/docs/reference/tailwind-plugins.md +21 -1
- package/docs/tutorials/getting-started.md +15 -1
- package/package.json +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { cva } from "class-variance-authority";
|
|
5
|
+
import { cn } from "../utils/cn";
|
|
6
|
+
import { Themes } from "../utils/types";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
|
|
8
|
+
import { Icon, Input, Group } from "./Input";
|
|
9
|
+
import { Button, LoadingIcon } from "./Button";
|
|
10
|
+
import { useClickOutside } from "../hooks/useClickOutside";
|
|
11
|
+
// Reuse the exact menu-item styling so rows look identical to DropdownMenuItem.
|
|
12
|
+
import { MenuItemStyles } from "./DropdownMenu";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SearchableSelect — a searchable single-select combobox.
|
|
16
|
+
*
|
|
17
|
+
* Same search-on-focus behavior as SearchableTable, but the dropdown renders
|
|
18
|
+
* DropdownMenu-style rows (MenuItemStyles) instead of a table. Built on Popover
|
|
19
|
+
* so the text input keeps focus while filtering. Click an option to select it:
|
|
20
|
+
* the dropdown closes and the input shows the option's label.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface SearchableSelectOption {
|
|
24
|
+
value: string;
|
|
25
|
+
label: string;
|
|
26
|
+
icon?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
options: SearchableSelectOption[];
|
|
31
|
+
/** Controlled selected value (optional). */
|
|
32
|
+
value?: string | null;
|
|
33
|
+
onValueChange?: (value: string, option: SearchableSelectOption) => void;
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
size?: "XS" | "S" | "M";
|
|
36
|
+
variant?: "SystemStyle" | "PresentationStyle";
|
|
37
|
+
icon?: ReactNode;
|
|
38
|
+
theme?: Themes;
|
|
39
|
+
dir?: string;
|
|
40
|
+
className?: string;
|
|
41
|
+
|
|
42
|
+
// --- Async / backend pagination (all optional; static `options` still works) ---
|
|
43
|
+
/**
|
|
44
|
+
* When true (default), the component filters `options` by label locally.
|
|
45
|
+
* Set false for server-side search — `options` are rendered as-is and you
|
|
46
|
+
* refetch them in response to `onSearchChange`.
|
|
47
|
+
*/
|
|
48
|
+
filterClientSide?: boolean;
|
|
49
|
+
/** Debounced as the user types — refetch your data here (server search). */
|
|
50
|
+
onSearchChange?: (query: string) => void;
|
|
51
|
+
/** Debounce for `onSearchChange`, in ms (default 300). */
|
|
52
|
+
searchDebounceMs?: number;
|
|
53
|
+
/** Whether more pages are available; gates the infinite-scroll loader. */
|
|
54
|
+
hasMore?: boolean;
|
|
55
|
+
/** Whether a fetch is in flight; shows a loading row and blocks onLoadMore. */
|
|
56
|
+
loading?: boolean;
|
|
57
|
+
/** Called when the bottom sentinel scrolls into view and `hasMore && !loading`. */
|
|
58
|
+
onLoadMore?: () => void;
|
|
59
|
+
/** Max rows visible before the list scrolls (default 5). */
|
|
60
|
+
maxVisibleItems?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One M-size row ≈ 32px height + 2px*2 padding + 1px gap.
|
|
64
|
+
const ROW_HEIGHT = 37;
|
|
65
|
+
|
|
66
|
+
// Popover surface styled like the DropdownMenu (self-contained, mirrors the
|
|
67
|
+
// menu surface used by DropdownMenu/ContextMenu).
|
|
68
|
+
const menuContentStyles = cva(
|
|
69
|
+
[
|
|
70
|
+
"p-1",
|
|
71
|
+
"rounded-[14px]",
|
|
72
|
+
"border-0",
|
|
73
|
+
"outline-none",
|
|
74
|
+
"overflow-hidden", // the inner scroll viewport owns scrolling
|
|
75
|
+
"backdrop-blur-[21px]",
|
|
76
|
+
"flex flex-col gap-1",
|
|
77
|
+
"data-[state=open]:animate-in",
|
|
78
|
+
"data-[state=open]:fade-in-0",
|
|
79
|
+
],
|
|
80
|
+
{
|
|
81
|
+
variants: {
|
|
82
|
+
variant: {
|
|
83
|
+
PresentationStyle: [
|
|
84
|
+
"bg-[rgba(61,64,69,0.72)]",
|
|
85
|
+
"shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]",
|
|
86
|
+
],
|
|
87
|
+
SystemStyle: [
|
|
88
|
+
"bg-background-system-body-primary",
|
|
89
|
+
"shadow-[0px_0px_18px_0px_rgba(0,0,0,0.75)]",
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
defaultVariants: { variant: "PresentationStyle" },
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
export function SearchableSelect({
|
|
98
|
+
options,
|
|
99
|
+
value,
|
|
100
|
+
onValueChange,
|
|
101
|
+
placeholder = "Search…",
|
|
102
|
+
size = "M",
|
|
103
|
+
variant = "PresentationStyle",
|
|
104
|
+
icon,
|
|
105
|
+
theme,
|
|
106
|
+
dir,
|
|
107
|
+
className,
|
|
108
|
+
filterClientSide = true,
|
|
109
|
+
onSearchChange,
|
|
110
|
+
searchDebounceMs = 300,
|
|
111
|
+
hasMore = false,
|
|
112
|
+
loading = false,
|
|
113
|
+
onLoadMore,
|
|
114
|
+
maxVisibleItems = 5,
|
|
115
|
+
}: Props) {
|
|
116
|
+
const [open, setOpen] = useState(false);
|
|
117
|
+
const [search, setSearch] = useState("");
|
|
118
|
+
const [searching, setSearching] = useState(false);
|
|
119
|
+
const [dropdownWidth, setDropdownWidth] = useState(0);
|
|
120
|
+
const popoverContentRef = useRef<HTMLDivElement>(null);
|
|
121
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
122
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
123
|
+
|
|
124
|
+
const groupRef = useClickOutside<HTMLDivElement>((e) => {
|
|
125
|
+
const target = e?.target as Node;
|
|
126
|
+
if (
|
|
127
|
+
!groupRef.current?.contains(target) &&
|
|
128
|
+
!popoverContentRef.current?.contains(target)
|
|
129
|
+
) {
|
|
130
|
+
setOpen(false);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Debounced server search: notify the consumer to refetch when the query
|
|
135
|
+
// settles. Skipped entirely if no onSearchChange is provided (static mode).
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!onSearchChange) return;
|
|
138
|
+
const id = setTimeout(() => onSearchChange(search.trim()), searchDebounceMs);
|
|
139
|
+
return () => clearTimeout(id);
|
|
140
|
+
}, [search, onSearchChange, searchDebounceMs]);
|
|
141
|
+
|
|
142
|
+
// Infinite scroll: when the scroll viewport nears the bottom, ask the consumer
|
|
143
|
+
// to load the next page. An onScroll handler is more reliable here than an
|
|
144
|
+
// IntersectionObserver, which races with Radix's async portal mount.
|
|
145
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
146
|
+
if (!onLoadMore || !hasMore || loading) return;
|
|
147
|
+
const el = e.currentTarget;
|
|
148
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) {
|
|
149
|
+
onLoadMore();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const filteredOptions = useMemo(() => {
|
|
154
|
+
// Server-side search mode: render options as-is (already filtered upstream).
|
|
155
|
+
if (!filterClientSide) return options;
|
|
156
|
+
const q = search.trim().toLowerCase();
|
|
157
|
+
if (!q) return options;
|
|
158
|
+
return options.filter((o) => o.label.toLowerCase().includes(q));
|
|
159
|
+
}, [options, search, filterClientSide]);
|
|
160
|
+
|
|
161
|
+
const selectedOption = options.find((o) => o.value === value) ?? null;
|
|
162
|
+
|
|
163
|
+
const handleSelect = (option: SearchableSelectOption) => {
|
|
164
|
+
onValueChange?.(option.value, option);
|
|
165
|
+
setSearch("");
|
|
166
|
+
setSearching(false);
|
|
167
|
+
setOpen(false);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Solid value: search text while typing, otherwise the selected label.
|
|
171
|
+
const displayValue = searching ? search : selectedOption?.label ?? "";
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Popover open={open}>
|
|
175
|
+
<PopoverTrigger asChild>
|
|
176
|
+
<Group
|
|
177
|
+
dir={dir}
|
|
178
|
+
data-theme={theme}
|
|
179
|
+
variant={variant}
|
|
180
|
+
size={size === "XS" ? "S" : size}
|
|
181
|
+
ref={groupRef as never}
|
|
182
|
+
onFocus={(e: React.FocusEvent<HTMLDivElement>) =>
|
|
183
|
+
setDropdownWidth(e.currentTarget.offsetWidth)
|
|
184
|
+
}
|
|
185
|
+
className={cn("flex w-full items-center gap-1 p-1", className)}
|
|
186
|
+
>
|
|
187
|
+
{icon && <Icon>{icon}</Icon>}
|
|
188
|
+
<Input
|
|
189
|
+
ref={inputRef}
|
|
190
|
+
placeholder={placeholder}
|
|
191
|
+
value={displayValue}
|
|
192
|
+
onChange={(e) => {
|
|
193
|
+
setSearch(e.target.value);
|
|
194
|
+
setSearching(true);
|
|
195
|
+
}}
|
|
196
|
+
onFocus={() => {
|
|
197
|
+
setSearching(true);
|
|
198
|
+
setSearch("");
|
|
199
|
+
setOpen(true);
|
|
200
|
+
}}
|
|
201
|
+
onBlur={() => setSearching(false)}
|
|
202
|
+
className={cn("min-w-[100px] flex-1", {
|
|
203
|
+
"!h-[18px]": size === "XS",
|
|
204
|
+
"!h-[22px]": size === "S",
|
|
205
|
+
"!h-[24px]": size === "M",
|
|
206
|
+
})}
|
|
207
|
+
/>
|
|
208
|
+
{/* Chevron toggle — boxed icon button matching the Select component. */}
|
|
209
|
+
<Button
|
|
210
|
+
as="span"
|
|
211
|
+
buttonType="icon"
|
|
212
|
+
tabIndex={-1}
|
|
213
|
+
aria-label={open ? "Close" : "Open"}
|
|
214
|
+
onMouseDown={(e: React.MouseEvent) => {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
if (open) {
|
|
217
|
+
setOpen(false);
|
|
218
|
+
} else {
|
|
219
|
+
inputRef.current?.focus();
|
|
220
|
+
setOpen(true);
|
|
221
|
+
}
|
|
222
|
+
}}
|
|
223
|
+
className={cn(
|
|
224
|
+
"shrink-0 h-[32px] w-[32px] rounded-[4px]",
|
|
225
|
+
open && "bg-background-presentation-action-hover text-white"
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
<i
|
|
229
|
+
className={cn(
|
|
230
|
+
"ri-arrow-down-s-line text-[16px] transition-all duration-100 ease-in-out",
|
|
231
|
+
open && "rotate-180"
|
|
232
|
+
)}
|
|
233
|
+
/>
|
|
234
|
+
</Button>
|
|
235
|
+
</Group>
|
|
236
|
+
</PopoverTrigger>
|
|
237
|
+
|
|
238
|
+
<PopoverContent
|
|
239
|
+
dir={dir}
|
|
240
|
+
data-theme={theme}
|
|
241
|
+
ref={popoverContentRef}
|
|
242
|
+
variant={variant}
|
|
243
|
+
style={{ width: dropdownWidth || undefined }}
|
|
244
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
245
|
+
onWheel={(e) => e.stopPropagation()}
|
|
246
|
+
className={cn(menuContentStyles({ variant }))}
|
|
247
|
+
>
|
|
248
|
+
{/* Dedicated scroll viewport: caps height to ~maxVisibleItems rows and
|
|
249
|
+
scrolls the rest. Keeping it separate from the popover padding avoids
|
|
250
|
+
flex/overflow conflicts that block scrolling. */}
|
|
251
|
+
<div
|
|
252
|
+
ref={scrollRef}
|
|
253
|
+
onScroll={handleScroll}
|
|
254
|
+
className="overflow-y-auto overflow-x-hidden scrollbar-hide"
|
|
255
|
+
style={{ maxHeight: maxVisibleItems * ROW_HEIGHT }}
|
|
256
|
+
>
|
|
257
|
+
{filteredOptions.length > 0 && (
|
|
258
|
+
// Boxed group container — matches the DropdownMenu's auto-grouped look.
|
|
259
|
+
<div className="flex flex-col gap-[1px] rounded-[10px] overflow-hidden">
|
|
260
|
+
{filteredOptions.map((option) => {
|
|
261
|
+
const isSelected = option.value === value;
|
|
262
|
+
return (
|
|
263
|
+
// Same structure as DropdownMenuItem: MenuItemStyles on the
|
|
264
|
+
// element + a single inner <div> the styles target via [&>div].
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
key={option.value}
|
|
268
|
+
onClick={() => handleSelect(option)}
|
|
269
|
+
data-highlighted={isSelected ? "" : undefined}
|
|
270
|
+
className={cn(
|
|
271
|
+
MenuItemStyles({ variant: "Default", size: "M" }),
|
|
272
|
+
"shrink-0" // keep full row height; the list scrolls instead of squishing
|
|
273
|
+
)}
|
|
274
|
+
>
|
|
275
|
+
<div>
|
|
276
|
+
{option.icon}
|
|
277
|
+
<span className="flex-1 text-start">{option.label}</span>
|
|
278
|
+
{isSelected && (
|
|
279
|
+
<i className="ri-check-line text-[16px] shrink-0" />
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
</button>
|
|
283
|
+
);
|
|
284
|
+
})}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{/* Empty state — only when nothing is loading. */}
|
|
289
|
+
{filteredOptions.length === 0 && !loading && (
|
|
290
|
+
<div className="px-3 py-2 typography-body-small-regular text-white-alpha-75">
|
|
291
|
+
No results found
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{/* Loading row (initial load or fetching the next page). */}
|
|
296
|
+
{loading && (
|
|
297
|
+
<div className="flex items-center justify-center py-2">
|
|
298
|
+
<LoadingIcon size="M" />
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
</div>
|
|
303
|
+
</PopoverContent>
|
|
304
|
+
</Popover>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
SearchableSelect.displayName = "SearchableSelect";
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { cva } from "class-variance-authority";
|
|
5
|
+
import { cn } from "../utils/cn";
|
|
6
|
+
import { Themes } from "../utils/types";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
|
|
8
|
+
import { Icon, Input, Group } from "./Input";
|
|
9
|
+
import { Button, LoadingIcon } from "./Button";
|
|
10
|
+
import { useClickOutside } from "../hooks/useClickOutside";
|
|
11
|
+
import {
|
|
12
|
+
Table,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableBody,
|
|
15
|
+
TableHead,
|
|
16
|
+
TableRow,
|
|
17
|
+
TableCell,
|
|
18
|
+
} from "./Table";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* SearchableTable — a searchable combobox whose dropdown renders a real Table.
|
|
22
|
+
*
|
|
23
|
+
* Type in the input to filter rows; the dropdown opens on focus and shows the
|
|
24
|
+
* filtered data using the existing Table component (composed inside a Popover,
|
|
25
|
+
* the same mechanism BadgeField uses). Click a row to select it (single-select):
|
|
26
|
+
* the dropdown closes and the input shows the selected row's label.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface SearchableTableColumn<T> {
|
|
30
|
+
/** Property on the row used for default rendering and search. */
|
|
31
|
+
key: keyof T & string;
|
|
32
|
+
header: ReactNode;
|
|
33
|
+
/** Custom cell renderer; defaults to String(row[key]). */
|
|
34
|
+
render?: (row: T) => ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Props<T> {
|
|
38
|
+
columns: SearchableTableColumn<T>[];
|
|
39
|
+
rows: T[];
|
|
40
|
+
/** Controlled selected row (optional). */
|
|
41
|
+
value?: T | null;
|
|
42
|
+
onSelect?: (row: T) => void;
|
|
43
|
+
/** Text shown in the input after selection; defaults to the first column's value. */
|
|
44
|
+
getLabel?: (row: T) => string;
|
|
45
|
+
/** Stable id/key per row; defaults to JSON of the row. */
|
|
46
|
+
getRowId?: (row: T) => string;
|
|
47
|
+
/** Which fields the search matches; defaults to every column key. */
|
|
48
|
+
searchKeys?: (keyof T & string)[];
|
|
49
|
+
placeholder?: string;
|
|
50
|
+
size?: "XS" | "S" | "M";
|
|
51
|
+
variant?: "SystemStyle" | "PresentationStyle";
|
|
52
|
+
icon?: ReactNode;
|
|
53
|
+
theme?: Themes;
|
|
54
|
+
dir?: string;
|
|
55
|
+
className?: string;
|
|
56
|
+
|
|
57
|
+
// --- Async / backend pagination (all optional; static `rows` still works) ---
|
|
58
|
+
/**
|
|
59
|
+
* When true (default), the component filters `rows` by searchKeys locally.
|
|
60
|
+
* Set false for server-side search — `rows` are rendered as-is and you
|
|
61
|
+
* refetch them in response to `onSearchChange`.
|
|
62
|
+
*/
|
|
63
|
+
filterClientSide?: boolean;
|
|
64
|
+
/** Debounced as the user types — refetch your data here (server search). */
|
|
65
|
+
onSearchChange?: (query: string) => void;
|
|
66
|
+
/** Debounce for `onSearchChange`, in ms (default 300). */
|
|
67
|
+
searchDebounceMs?: number;
|
|
68
|
+
/** Whether more pages are available; gates the infinite-scroll loader. */
|
|
69
|
+
hasMore?: boolean;
|
|
70
|
+
/** Whether a fetch is in flight; shows a loading row and blocks onLoadMore. */
|
|
71
|
+
loading?: boolean;
|
|
72
|
+
/** Called when the list nears the bottom and `hasMore && !loading`. */
|
|
73
|
+
onLoadMore?: () => void;
|
|
74
|
+
/** Max rows visible before the list scrolls (default 6). */
|
|
75
|
+
maxVisibleRows?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// One M-size table row ≈ 40px + the 44px sticky header.
|
|
79
|
+
const ROW_HEIGHT = 40;
|
|
80
|
+
const HEADER_HEIGHT = 44;
|
|
81
|
+
|
|
82
|
+
// Popover surface styled like the DropdownMenu (kept local — self-contained).
|
|
83
|
+
const tableDropdownStyles = cva(
|
|
84
|
+
[
|
|
85
|
+
"p-1",
|
|
86
|
+
"rounded-[14px]",
|
|
87
|
+
"border-0", // neutralize PopoverContent's base border (the card provides its own)
|
|
88
|
+
"outline-none",
|
|
89
|
+
"overflow-y-auto",
|
|
90
|
+
"scrollbar-hide",
|
|
91
|
+
"backdrop-blur-[21px]",
|
|
92
|
+
"data-[state=open]:animate-in",
|
|
93
|
+
"data-[state=open]:fade-in-0",
|
|
94
|
+
],
|
|
95
|
+
{
|
|
96
|
+
variants: {
|
|
97
|
+
variant: {
|
|
98
|
+
PresentationStyle: [
|
|
99
|
+
"bg-[rgba(61,64,69,0.72)]",
|
|
100
|
+
"shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]",
|
|
101
|
+
],
|
|
102
|
+
SystemStyle: [
|
|
103
|
+
"bg-background-system-body-primary",
|
|
104
|
+
"shadow-[0px_0px_18px_0px_rgba(0,0,0,0.75)]",
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
defaultVariants: { variant: "PresentationStyle" },
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
export function SearchableTable<T extends Record<string, unknown>>({
|
|
113
|
+
columns,
|
|
114
|
+
rows,
|
|
115
|
+
value,
|
|
116
|
+
onSelect,
|
|
117
|
+
getLabel,
|
|
118
|
+
getRowId,
|
|
119
|
+
searchKeys,
|
|
120
|
+
placeholder = "Search…",
|
|
121
|
+
size = "M",
|
|
122
|
+
variant = "PresentationStyle",
|
|
123
|
+
icon,
|
|
124
|
+
theme,
|
|
125
|
+
dir,
|
|
126
|
+
className,
|
|
127
|
+
filterClientSide = true,
|
|
128
|
+
onSearchChange,
|
|
129
|
+
searchDebounceMs = 300,
|
|
130
|
+
hasMore = false,
|
|
131
|
+
loading = false,
|
|
132
|
+
onLoadMore,
|
|
133
|
+
maxVisibleRows = 6,
|
|
134
|
+
}: Props<T>) {
|
|
135
|
+
const [open, setOpen] = useState(false);
|
|
136
|
+
const [search, setSearch] = useState("");
|
|
137
|
+
const [dropdownWidth, setDropdownWidth] = useState(0);
|
|
138
|
+
const popoverContentRef = useRef<HTMLDivElement>(null);
|
|
139
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
140
|
+
|
|
141
|
+
// Debounced server search: notify the consumer to refetch when the query
|
|
142
|
+
// settles. Skipped entirely if no onSearchChange is provided (static mode).
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!onSearchChange) return;
|
|
145
|
+
const id = setTimeout(() => onSearchChange(search.trim()), searchDebounceMs);
|
|
146
|
+
return () => clearTimeout(id);
|
|
147
|
+
}, [search, onSearchChange, searchDebounceMs]);
|
|
148
|
+
|
|
149
|
+
// Infinite scroll: when the vertical scroll viewport nears the bottom, ask the
|
|
150
|
+
// consumer to load the next page (onScroll is reliable across Radix's portal).
|
|
151
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
152
|
+
if (!onLoadMore || !hasMore || loading) return;
|
|
153
|
+
const el = e.currentTarget;
|
|
154
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) {
|
|
155
|
+
onLoadMore();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const groupRef = useClickOutside<HTMLDivElement>((e) => {
|
|
160
|
+
const target = e?.target as Node;
|
|
161
|
+
if (
|
|
162
|
+
!groupRef.current?.contains(target) &&
|
|
163
|
+
!popoverContentRef.current?.contains(target)
|
|
164
|
+
) {
|
|
165
|
+
setOpen(false);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const rowId = (row: T) => getRowId?.(row) ?? JSON.stringify(row);
|
|
170
|
+
const label = (row: T) =>
|
|
171
|
+
getLabel?.(row) ?? String(row[columns[0]?.key] ?? "");
|
|
172
|
+
|
|
173
|
+
const keysToSearch = searchKeys ?? columns.map((c) => c.key);
|
|
174
|
+
|
|
175
|
+
const filteredRows = useMemo(() => {
|
|
176
|
+
// Server-side search mode: render rows as-is (already filtered upstream).
|
|
177
|
+
if (!filterClientSide) return rows;
|
|
178
|
+
const q = search.trim().toLowerCase();
|
|
179
|
+
if (!q) return rows;
|
|
180
|
+
return rows.filter((row) =>
|
|
181
|
+
keysToSearch.some((k) =>
|
|
182
|
+
String(row[k] ?? "").toLowerCase().includes(q)
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
}, [rows, search, keysToSearch, filterClientSide]);
|
|
186
|
+
|
|
187
|
+
const selectedId = value ? rowId(value) : undefined;
|
|
188
|
+
// True while the user is actively typing a query. When false, the input shows
|
|
189
|
+
// the selected row's label as its real value (solid text, not placeholder).
|
|
190
|
+
const [searching, setSearching] = useState(false);
|
|
191
|
+
|
|
192
|
+
const handleSelect = (row: T) => {
|
|
193
|
+
onSelect?.(row);
|
|
194
|
+
setSearch("");
|
|
195
|
+
setSearching(false);
|
|
196
|
+
setOpen(false);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Solid value: search text while typing, otherwise the selected label.
|
|
200
|
+
const displayValue = searching ? search : value ? label(value) : "";
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<Popover open={open}>
|
|
204
|
+
<PopoverTrigger asChild>
|
|
205
|
+
<Group
|
|
206
|
+
dir={dir}
|
|
207
|
+
data-theme={theme}
|
|
208
|
+
variant={variant}
|
|
209
|
+
size={size === "XS" ? "S" : size}
|
|
210
|
+
ref={groupRef as never}
|
|
211
|
+
onFocus={(e: React.FocusEvent<HTMLDivElement>) =>
|
|
212
|
+
setDropdownWidth(e.currentTarget.offsetWidth)
|
|
213
|
+
}
|
|
214
|
+
className={cn("flex w-full items-center gap-1 p-1", className)}
|
|
215
|
+
>
|
|
216
|
+
{icon && <Icon>{icon}</Icon>}
|
|
217
|
+
<Input
|
|
218
|
+
ref={inputRef}
|
|
219
|
+
placeholder={placeholder}
|
|
220
|
+
value={displayValue}
|
|
221
|
+
onChange={(e) => {
|
|
222
|
+
setSearch(e.target.value);
|
|
223
|
+
setSearching(true);
|
|
224
|
+
}}
|
|
225
|
+
onFocus={() => {
|
|
226
|
+
// Start a fresh search; the selected label clears so the user can type.
|
|
227
|
+
setSearching(true);
|
|
228
|
+
setSearch("");
|
|
229
|
+
setOpen(true);
|
|
230
|
+
}}
|
|
231
|
+
onBlur={() => setSearching(false)}
|
|
232
|
+
className={cn("min-w-[100px] flex-1", {
|
|
233
|
+
"!h-[18px]": size === "XS",
|
|
234
|
+
"!h-[22px]": size === "S",
|
|
235
|
+
"!h-[24px]": size === "M",
|
|
236
|
+
})}
|
|
237
|
+
/>
|
|
238
|
+
{/* Chevron toggle — boxed icon button matching the Select component. */}
|
|
239
|
+
<Button
|
|
240
|
+
as="span"
|
|
241
|
+
buttonType="icon"
|
|
242
|
+
tabIndex={-1}
|
|
243
|
+
aria-label={open ? "Close" : "Open"}
|
|
244
|
+
onMouseDown={(e: React.MouseEvent) => {
|
|
245
|
+
// Prevent the input's blur/focus race; toggle the dropdown.
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
if (open) {
|
|
248
|
+
setOpen(false);
|
|
249
|
+
} else {
|
|
250
|
+
inputRef.current?.focus();
|
|
251
|
+
setOpen(true);
|
|
252
|
+
}
|
|
253
|
+
}}
|
|
254
|
+
className={cn(
|
|
255
|
+
"shrink-0 h-[32px] w-[32px] rounded-[4px]",
|
|
256
|
+
open && "bg-background-presentation-action-hover text-white"
|
|
257
|
+
)}
|
|
258
|
+
>
|
|
259
|
+
<i
|
|
260
|
+
className={cn(
|
|
261
|
+
"ri-arrow-down-s-line text-[16px] transition-all duration-100 ease-in-out",
|
|
262
|
+
open && "rotate-180"
|
|
263
|
+
)}
|
|
264
|
+
/>
|
|
265
|
+
</Button>
|
|
266
|
+
</Group>
|
|
267
|
+
</PopoverTrigger>
|
|
268
|
+
|
|
269
|
+
<PopoverContent
|
|
270
|
+
dir={dir}
|
|
271
|
+
data-theme={theme}
|
|
272
|
+
ref={popoverContentRef}
|
|
273
|
+
variant={variant}
|
|
274
|
+
onScroll={handleScroll}
|
|
275
|
+
// Lock the dropdown to the input's width; the table scrolls horizontally
|
|
276
|
+
// inside it. Cap height to ~maxVisibleRows (+ header) so the rest scrolls.
|
|
277
|
+
style={{
|
|
278
|
+
width: dropdownWidth || undefined,
|
|
279
|
+
maxHeight: HEADER_HEIGHT + maxVisibleRows * ROW_HEIGHT + 8,
|
|
280
|
+
}}
|
|
281
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
282
|
+
className={cn(tableDropdownStyles({ variant }))}
|
|
283
|
+
>
|
|
284
|
+
{filteredRows.length > 0 && (
|
|
285
|
+
// Table-DDV wrapper: frosted bordered card around the table (Figma).
|
|
286
|
+
// The radius + overflow-hidden also go on the <table> itself, because a
|
|
287
|
+
// <table>'s corner cells paint to square edges and otherwise cover an
|
|
288
|
+
// ancestor's rounded corners.
|
|
289
|
+
<div className="rounded-[10px] border overflow-x-auto border-white-alpha-40 bg-[rgba(184,192,204,0.5)]">
|
|
290
|
+
<Table
|
|
291
|
+
theme={theme as never}
|
|
292
|
+
data-theme="dark"
|
|
293
|
+
// Natural width (w-max) so columns keep their sizes and the wrapper
|
|
294
|
+
// scrolls horizontally instead of squeezing them into the input width.
|
|
295
|
+
className="w-max rounded-[10px] overflow-hidden"
|
|
296
|
+
>
|
|
297
|
+
{/* The blurred dark header bar is painted on the TableHEAD cells
|
|
298
|
+
(which fill the row); bg/height on a <tr> doesn't render
|
|
299
|
+
reliably because cells paint over it. */}
|
|
300
|
+
<TableHeader className=" bg-black-alpha-20 shadow-[0px_4px_8px_0px_rgba(0,0,0,0.15)]">
|
|
301
|
+
<TableRow className="h-[44px] [&_button]:bg-white-alpha-40 [&>th]:border-white-alpha-20">
|
|
302
|
+
{columns.map((col) => (
|
|
303
|
+
<TableHead
|
|
304
|
+
key={col.key}
|
|
305
|
+
// White semibold header text from the design.
|
|
306
|
+
className="cursor-default typography-body-medium-medium text-white"
|
|
307
|
+
>
|
|
308
|
+
{col.header}
|
|
309
|
+
</TableHead>
|
|
310
|
+
))}
|
|
311
|
+
</TableRow>
|
|
312
|
+
</TableHeader>
|
|
313
|
+
<TableBody>
|
|
314
|
+
{filteredRows.map((row) => {
|
|
315
|
+
const id = rowId(row);
|
|
316
|
+
return (
|
|
317
|
+
<TableRow
|
|
318
|
+
key={id}
|
|
319
|
+
state={selectedId === id ? "selected" : undefined}
|
|
320
|
+
onClick={() => handleSelect(row)}
|
|
321
|
+
// h-[40px] rows with a translucent white bottom rule.
|
|
322
|
+
className={cn("cursor-pointer h-[40px] ", selectedId === id && "bg-white-alpha-50")}
|
|
323
|
+
>
|
|
324
|
+
{columns.map((col) => (
|
|
325
|
+
<TableCell
|
|
326
|
+
key={col.key}
|
|
327
|
+
// White body text; whitespace-nowrap (overriding the
|
|
328
|
+
// component's break-all) keeps columns wide so the
|
|
329
|
+
// table overflows horizontally instead of wrapping.
|
|
330
|
+
className="border-b border-white-alpha-20 text-white px-[8px] !whitespace-nowrap !break-normal"
|
|
331
|
+
>
|
|
332
|
+
{col.render
|
|
333
|
+
? col.render(row)
|
|
334
|
+
: String(row[col.key] ?? "")}
|
|
335
|
+
</TableCell>
|
|
336
|
+
))}
|
|
337
|
+
</TableRow>
|
|
338
|
+
);
|
|
339
|
+
})}
|
|
340
|
+
</TableBody>
|
|
341
|
+
</Table>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
{/* Empty state — only when nothing is loading. */}
|
|
346
|
+
{filteredRows.length === 0 && !loading && (
|
|
347
|
+
<div className="px-3 py-4 typography-body-small-regular text-white-alpha-75">
|
|
348
|
+
No results found
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
|
|
352
|
+
{/* Loading row (initial load or fetching the next page). */}
|
|
353
|
+
{loading && (
|
|
354
|
+
<div className="flex items-center justify-center py-2">
|
|
355
|
+
<LoadingIcon size="M" />
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
</PopoverContent>
|
|
359
|
+
</Popover>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
SearchableTable.displayName = "SearchableTable";
|
|
@@ -103,12 +103,12 @@ TableRow.displayName = "TableRow";
|
|
|
103
103
|
const TableHead = React.forwardRef<
|
|
104
104
|
HTMLTableCellElement,
|
|
105
105
|
React.ThHTMLAttributes<HTMLTableCellElement> &
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
TableHeadVariantsProps &
|
|
107
|
+
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
108
|
+
sortType?: "asc" | "desc" | undefined;
|
|
109
|
+
onSort?: () => void;
|
|
110
|
+
isDummy?: boolean;
|
|
111
|
+
}
|
|
112
112
|
>(
|
|
113
113
|
(
|
|
114
114
|
{ className, size = "M", disabled, sortType, onSort, isDummy, ...props },
|