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.
@@ -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
- TableHeadVariantsProps &
107
- React.ButtonHTMLAttributes<HTMLButtonElement> & {
108
- sortType?: "asc" | "desc" | undefined;
109
- onSort?: () => void;
110
- isDummy?: boolean;
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 },