torch-glare 2.2.1 → 2.4.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.
@@ -251,53 +251,53 @@ export function SearchableSelect({
251
251
  <div
252
252
  ref={scrollRef}
253
253
  onScroll={handleScroll}
254
- className="overflow-y-auto overflow-x-hidden scrollbar-hide"
254
+ className="overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide"
255
255
  style={{ maxHeight: maxVisibleItems * ROW_HEIGHT }}
256
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" />
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
280
273
  )}
281
- </div>
282
- </button>
283
- );
284
- })}
285
- </div>
286
- )}
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
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
- )}
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
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
- )}
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
301
 
302
302
  </div>
303
303
  </PopoverContent>
@@ -1,13 +1,24 @@
1
1
  "use client";
2
2
 
3
- import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
4
- import { cva } from "class-variance-authority";
3
+ import {
4
+ forwardRef,
5
+ InputHTMLAttributes,
6
+ ReactNode,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
5
12
  import { cn } from "../utils/cn";
6
13
  import { Themes } from "../utils/types";
7
- import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
14
+ import {
15
+ Dialog,
16
+ DialogTrigger,
17
+ DialogContent,
18
+ DialogTitle,
19
+ } from "./Dialog";
8
20
  import { Icon, Input, Group } from "./Input";
9
- import { Button, LoadingIcon } from "./Button";
10
- import { useClickOutside } from "../hooks/useClickOutside";
21
+ import { LoadingIcon } from "./Button";
11
22
  import {
12
23
  Table,
13
24
  TableHeader,
@@ -18,12 +29,12 @@ import {
18
29
  } from "./Table";
19
30
 
20
31
  /**
21
- * SearchableTable — a searchable combobox whose dropdown renders a real Table.
32
+ * SearchableTable — a field that opens a modal Dialog to pick a row from a Table.
22
33
  *
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.
34
+ * The trigger shows a placeholder until something is selected, then shows the
35
+ * selected row's label. Clicking it opens a dialog containing a search input and
36
+ * the data Table; clicking a row selects it, sets the value, and closes the
37
+ * dialog. Supports client- or server-side search and infinite-scroll pagination.
27
38
  */
28
39
 
29
40
  export interface SearchableTableColumn<T> {
@@ -40,13 +51,18 @@ interface Props<T> {
40
51
  /** Controlled selected row (optional). */
41
52
  value?: T | null;
42
53
  onSelect?: (row: T) => void;
43
- /** Text shown in the input after selection; defaults to the first column's value. */
54
+ /** Text shown on the trigger after selection; defaults to the first column's value. */
44
55
  getLabel?: (row: T) => string;
45
56
  /** Stable id/key per row; defaults to JSON of the row. */
46
57
  getRowId?: (row: T) => string;
47
58
  /** Which fields the search matches; defaults to every column key. */
48
59
  searchKeys?: (keyof T & string)[];
60
+ /** Trigger placeholder shown until a row is selected. */
49
61
  placeholder?: string;
62
+ /** Placeholder for the search input inside the dialog. */
63
+ searchPlaceholder?: string;
64
+ /** Label shown on the dialog's search field. */
65
+ title?: string;
50
66
  size?: "XS" | "S" | "M";
51
67
  variant?: "SystemStyle" | "PresentationStyle";
52
68
  icon?: ReactNode;
@@ -71,44 +87,8 @@ interface Props<T> {
71
87
  loading?: boolean;
72
88
  /** Called when the list nears the bottom and `hasMore && !loading`. */
73
89
  onLoadMore?: () => void;
74
- /** Max rows visible before the list scrolls (default 6). */
75
- maxVisibleRows?: number;
76
90
  }
77
91
 
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
92
  export function SearchableTable<T extends Record<string, unknown>>({
113
93
  columns,
114
94
  rows,
@@ -117,7 +97,9 @@ export function SearchableTable<T extends Record<string, unknown>>({
117
97
  getLabel,
118
98
  getRowId,
119
99
  searchKeys,
120
- placeholder = "Search…",
100
+ placeholder = "Select…",
101
+ searchPlaceholder = "Search…",
102
+ title = "Select an item",
121
103
  size = "M",
122
104
  variant = "PresentationStyle",
123
105
  icon,
@@ -130,14 +112,18 @@ export function SearchableTable<T extends Record<string, unknown>>({
130
112
  hasMore = false,
131
113
  loading = false,
132
114
  onLoadMore,
133
- maxVisibleRows = 6,
134
115
  }: Props<T>) {
135
116
  const [open, setOpen] = useState(false);
136
117
  const [search, setSearch] = useState("");
137
- const [dropdownWidth, setDropdownWidth] = useState(0);
138
- const popoverContentRef = useRef<HTMLDivElement>(null);
118
+ const scrollRef = useRef<HTMLDivElement>(null);
139
119
  const inputRef = useRef<HTMLInputElement | null>(null);
140
120
 
121
+ const rowId = (row: T) => getRowId?.(row) ?? JSON.stringify(row);
122
+ const label = (row: T) =>
123
+ getLabel?.(row) ?? String(row[columns[0]?.key] ?? "");
124
+
125
+ const keysToSearch = searchKeys ?? columns.map((c) => c.key);
126
+
141
127
  // Debounced server search: notify the consumer to refetch when the query
142
128
  // settles. Skipped entirely if no onSearchChange is provided (static mode).
143
129
  useEffect(() => {
@@ -146,8 +132,13 @@ export function SearchableTable<T extends Record<string, unknown>>({
146
132
  return () => clearTimeout(id);
147
133
  }, [search, onSearchChange, searchDebounceMs]);
148
134
 
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).
135
+ // Reset the query whenever the dialog closes so it reopens clean.
136
+ useEffect(() => {
137
+ if (!open) setSearch("");
138
+ }, [open]);
139
+
140
+ // Infinite scroll: when the scroll viewport nears the bottom, ask the consumer
141
+ // to load the next page.
151
142
  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
152
143
  if (!onLoadMore || !hasMore || loading) return;
153
144
  const el = e.currentTarget;
@@ -156,22 +147,6 @@ export function SearchableTable<T extends Record<string, unknown>>({
156
147
  }
157
148
  };
158
149
 
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
150
  const filteredRows = useMemo(() => {
176
151
  // Server-side search mode: render rows as-is (already filtered upstream).
177
152
  if (!filterClientSide) return rows;
@@ -185,124 +160,87 @@ export function SearchableTable<T extends Record<string, unknown>>({
185
160
  }, [rows, search, keysToSearch, filterClientSide]);
186
161
 
187
162
  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
163
 
192
164
  const handleSelect = (row: T) => {
193
165
  onSelect?.(row);
194
- setSearch("");
195
- setSearching(false);
196
166
  setOpen(false);
197
167
  };
198
168
 
199
- // Solid value: search text while typing, otherwise the selected label.
200
- const displayValue = searching ? search : value ? label(value) : "";
201
-
202
169
  return (
203
- <Popover open={open}>
204
- <PopoverTrigger asChild>
170
+ <Dialog open={open} onOpenChange={setOpen}>
171
+ <DialogTrigger asChild>
172
+ {/* Trigger: shows placeholder until a row is selected, then its label. */}
205
173
  <Group
206
174
  dir={dir}
207
175
  data-theme={theme}
208
176
  variant={variant}
209
177
  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)}
178
+ role="button"
179
+ tabIndex={0}
180
+ className={cn(
181
+ "flex w-full items-center gap-1 p-1 cursor-pointer",
182
+ className
183
+ )}
215
184
  >
216
185
  {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
- }}
186
+ <span
254
187
  className={cn(
255
- "shrink-0 h-[32px] w-[32px] rounded-[4px]",
256
- open && "bg-background-presentation-action-hover text-white"
188
+ "flex-1 px-1 truncate typography-body-medium-regular",
189
+ value
190
+ ? "text-content-presentation-action-light-primary"
191
+ : "text-content-presentation-action-light-secondary"
257
192
  )}
258
193
  >
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>
194
+ {value ? label(value) : placeholder}
195
+ </span>
196
+ <i className="ri-arrow-down-s-line text-[16px] shrink-0 text-content-presentation-action-light-primary" />
266
197
  </Group>
267
- </PopoverTrigger>
198
+ </DialogTrigger>
268
199
 
269
- <PopoverContent
200
+ <DialogContent
270
201
  dir={dir}
271
202
  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,
203
+ onOpenAutoFocus={(e) => {
204
+ // Focus the search input instead of the first row.
205
+ e.preventDefault();
206
+ inputRef.current?.focus();
280
207
  }}
281
- onOpenAutoFocus={(e) => e.preventDefault()}
282
- className={cn(tableDropdownStyles({ variant }))}
208
+ className={cn(
209
+ "w-[min(640px,90vw)] bg-transparent !items-stretch rounded-[14px] gap-3 shadow-none",
210
+ )}
283
211
  >
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)]">
212
+ {/* Visually-hidden title for accessibility (Radix requires a DialogTitle). */}
213
+ <DialogTitle className="sr-only">{title ?? "Select an item"}</DialogTitle>
214
+
215
+ {/* Search input local inline-label field (full control over colors). */}
216
+ <SearchInput
217
+ ref={inputRef}
218
+ variant={variant}
219
+ theme={theme}
220
+ dir={dir}
221
+ label={title}
222
+ value={search}
223
+ placeholder={searchPlaceholder}
224
+ onChange={(e) => setSearch(e.target.value)}
225
+ />
226
+
227
+ {/* Scrollable table area */}
228
+ <div
229
+ ref={scrollRef}
230
+ onScroll={handleScroll}
231
+ className="max-h-[55vh] overflow-auto scrollbar-hide rounded-[10px] border border-white-alpha-40 bg-[rgba(184,192,204,0.5)]"
232
+ >
233
+ {filteredRows.length > 0 && (
290
234
  <Table
291
235
  theme={theme as never}
292
236
  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"
237
+ className="w-full rounded-[10px] overflow-hidden"
296
238
  >
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)]">
239
+ <TableHeader className="bg-black-alpha-20 shadow-[0px_4px_8px_0px_rgba(0,0,0,0.15)]">
301
240
  <TableRow className="h-[44px] [&_button]:bg-white-alpha-40 [&>th]:border-white-alpha-20">
302
241
  {columns.map((col) => (
303
242
  <TableHead
304
243
  key={col.key}
305
- // White semibold header text from the design.
306
244
  className="cursor-default typography-body-medium-medium text-white"
307
245
  >
308
246
  {col.header}
@@ -318,15 +256,14 @@ export function SearchableTable<T extends Record<string, unknown>>({
318
256
  key={id}
319
257
  state={selectedId === id ? "selected" : undefined}
320
258
  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")}
259
+ className={cn(
260
+ "cursor-pointer h-[40px]",
261
+ selectedId === id && "bg-white-alpha-50"
262
+ )}
323
263
  >
324
264
  {columns.map((col) => (
325
265
  <TableCell
326
266
  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
267
  className="border-b border-white-alpha-20 text-white px-[8px] !whitespace-nowrap !break-normal"
331
268
  >
332
269
  {col.render
@@ -339,25 +276,78 @@ export function SearchableTable<T extends Record<string, unknown>>({
339
276
  })}
340
277
  </TableBody>
341
278
  </Table>
342
- </div>
343
- )}
279
+ )}
344
280
 
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
- )}
281
+ {/* Empty state — only when nothing is loading. */}
282
+ {filteredRows.length === 0 && !loading && (
283
+ <div className="px-3 py-6 text-center typography-body-small-regular text-white-alpha-75">
284
+ No results found
285
+ </div>
286
+ )}
351
287
 
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>
288
+ {/* Loading row (initial load or fetching the next page). */}
289
+ {loading && (
290
+ <div className="flex items-center justify-center py-3">
291
+ <LoadingIcon size="M" />
292
+ </div>
293
+ )}
294
+ </div>
295
+ </DialogContent>
296
+ </Dialog>
360
297
  );
361
298
  }
362
299
 
363
300
  SearchableTable.displayName = "SearchableTable";
301
+
302
+ /* -------------------------------------------------------------------------- */
303
+ /* SearchInput — local inline-label search field (replaces InnerLabelField). */
304
+ /* The whole field + its text turn white on hover/focus, fully controlled here. */
305
+ /* -------------------------------------------------------------------------- */
306
+
307
+ interface SearchInputProps
308
+ extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
309
+ variant?: "SystemStyle" | "PresentationStyle";
310
+ theme?: Themes;
311
+ label?: string;
312
+ }
313
+
314
+ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
315
+ ({ variant = "PresentationStyle", theme, label, className, ...props }, ref) => (
316
+ <Group
317
+ data-theme={theme}
318
+ variant={variant}
319
+ size="M"
320
+ className={cn(
321
+ "group flex w-full items-center gap-2 p-1 rounded-[10px] transition-colors",
322
+ "border border-white-alpha-40 bg-[rgba(184,192,204,0.5)]",
323
+ "hover:bg-background-presentation-table-row-hover",
324
+ "focus-within:bg-background-presentation-table-row-hover",
325
+ className
326
+ )}
327
+ >
328
+ <Icon className="shrink-0">
329
+ <i className="ri-search-line text-content-presentation-global-secondary transition-colors group-hover:text-white group-focus-within:text-white" />
330
+ </Icon>
331
+
332
+ {label && (
333
+ <>
334
+ <span className="shrink-0 px-1 typography-labels-small-regular text-content-presentation-global-secondary text-[14px] text-white transition-colors group-hover:text-white group-focus-within:text-white">
335
+ {label}
336
+ </span>
337
+ <span className="h-[14px] w-px shrink-0 rounded-full bg-border-presentation-action-labelless-divider transition-colors group-hover:bg-white-alpha-40" />
338
+ </>
339
+ )}
340
+
341
+ <Input
342
+ ref={ref}
343
+ {...props}
344
+ className={cn(
345
+ "min-w-[100px] flex-1 !h-[24px] bg-transparent",
346
+ "text-content-presentation-action-light-primary",
347
+ "transition-colors group-hover:!text-white group-focus-within:!text-white"
348
+ )}
349
+ />
350
+ </Group>
351
+ )
352
+ );
353
+ SearchInput.displayName = "SearchInput";