torch-glare 2.3.0 → 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.
- package/apps/lib/components/Badge.tsx +6 -0
- package/apps/lib/components/ContextMenu.tsx +14 -11
- package/apps/lib/components/DataViews/DataViewRadio.tsx +9 -2
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +8 -8
- package/apps/lib/components/DataViews/FilterPanel.tsx +26 -9
- package/apps/lib/components/DataViews/filters/DatePickerRangeFilter.tsx +80 -0
- package/apps/lib/components/DataViews/types.ts +8 -0
- package/apps/lib/components/DatePicker.tsx +6 -1
- package/apps/lib/components/DropdownMenu.tsx +14 -11
- package/apps/lib/components/HeaderBar.tsx +127 -0
- package/apps/lib/components/SearchableSelect.tsx +42 -42
- package/apps/lib/components/SearchableTable.tsx +167 -177
- package/docs/components/context-menu.md +30 -0
- package/docs/components/data-views-config-panel.md +3 -3
- package/docs/components/data-views-layout.md +7 -1
- package/docs/components/dropdown-menu.md +28 -0
- package/docs/components/header-bar.md +181 -0
- package/docs/components/searchable-table.md +44 -30
- package/docs/components/section-block.md +118 -0
- package/docs/how-to/data-views-from-backend-response.md +1 -0
- package/package.json +1 -1
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
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 {
|
|
14
|
+
import {
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogTrigger,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
} from "./Dialog";
|
|
8
20
|
import { Icon, Input, Group } from "./Input";
|
|
9
|
-
import {
|
|
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
|
|
32
|
+
* SearchableTable — a field that opens a modal Dialog to pick a row from a Table.
|
|
22
33
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the
|
|
26
|
-
*
|
|
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
|
|
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 = "
|
|
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
|
|
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
|
-
//
|
|
150
|
-
|
|
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
|
-
<
|
|
204
|
-
<
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
<
|
|
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
|
-
"
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
</
|
|
198
|
+
</DialogTrigger>
|
|
268
199
|
|
|
269
|
-
<
|
|
200
|
+
<DialogContent
|
|
270
201
|
dir={dir}
|
|
271
202
|
data-theme={theme}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
282
|
-
|
|
208
|
+
className={cn(
|
|
209
|
+
"w-[min(640px,90vw)] bg-transparent !items-stretch rounded-[14px] gap-3 shadow-none",
|
|
210
|
+
)}
|
|
283
211
|
>
|
|
284
|
-
{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
343
|
-
)}
|
|
279
|
+
)}
|
|
344
280
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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";
|
|
@@ -233,6 +233,33 @@ function RtlMenu() {
|
|
|
233
233
|
}
|
|
234
234
|
```
|
|
235
235
|
|
|
236
|
+
### Long Menu (max height + scroll)
|
|
237
|
+
|
|
238
|
+
Tall menus scroll instead of overflowing off-screen. The surface caps at `maxHeight` (default `320`px) and never exceeds the space available after collision handling. Pass `maxHeight` to change the cap.
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuLabel } from '@torch-ui/components'
|
|
242
|
+
|
|
243
|
+
function LongMenu() {
|
|
244
|
+
return (
|
|
245
|
+
<ContextMenu>
|
|
246
|
+
<ContextMenuTrigger asChild>
|
|
247
|
+
<div className="flex h-40 w-72 items-center justify-center rounded-md border border-dashed">
|
|
248
|
+
Right-click here
|
|
249
|
+
</div>
|
|
250
|
+
</ContextMenuTrigger>
|
|
251
|
+
{/* Cap the surface at 220px — the rest scrolls. */}
|
|
252
|
+
<ContextMenuContent maxHeight={220}>
|
|
253
|
+
<ContextMenuLabel>Jump to section</ContextMenuLabel>
|
|
254
|
+
{Array.from({ length: 20 }, (_, i) => (
|
|
255
|
+
<ContextMenuItem key={i}>Section {i + 1}</ContextMenuItem>
|
|
256
|
+
))}
|
|
257
|
+
</ContextMenuContent>
|
|
258
|
+
</ContextMenu>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
236
263
|
## API Reference
|
|
237
264
|
|
|
238
265
|
### ContextMenu (Root)
|
|
@@ -264,6 +291,7 @@ The right-click zone. Wrap it around the element the menu should open from.
|
|
|
264
291
|
| `theme` | `'dark' \| 'light' \| 'default'` | - | Theme variant (applied as `data-theme`) |
|
|
265
292
|
| `className` | `string` | - | Additional CSS classes |
|
|
266
293
|
| `collisionPadding` | `number` | `8` | Min distance kept from the viewport edge |
|
|
294
|
+
| `maxHeight` | `number` | `320` | Max height (px) of the surface before it scrolls. Capped at `min(maxHeight, available-height)` so the menu never overflows off-screen |
|
|
267
295
|
| `autoGroup` | `boolean` | `true` | Auto-wrap loose items in a Boxed group (see Behavior notes) |
|
|
268
296
|
|
|
269
297
|
### ContextMenuItem
|
|
@@ -353,6 +381,7 @@ interface ContextMenuContentProps {
|
|
|
353
381
|
theme?: 'dark' | 'light' | 'default'
|
|
354
382
|
className?: string
|
|
355
383
|
collisionPadding?: number // default 8
|
|
384
|
+
maxHeight?: number // default 320 — surface scrolls past this
|
|
356
385
|
autoGroup?: boolean // default true
|
|
357
386
|
}
|
|
358
387
|
|
|
@@ -403,6 +432,7 @@ export const ContextMenuRadioItem: React.ForwardRefExoticComponent<ContextMenuRa
|
|
|
403
432
|
- **Opens at the pointer**: the menu opens on right-click (`contextmenu`) at the exact cursor position, not anchored to a fixed trigger button.
|
|
404
433
|
- **Second right-click closes it**: the Root is made controlled and tracks `open` in context. The Trigger listens in the capture phase, and when the menu is already open it `preventDefault()` / `stopPropagation()` and closes — so a second right-click dismisses instead of re-anchoring (which Radix handles unreliably).
|
|
405
434
|
- **Auto-grouping**: by default (`autoGroup` on `ContextMenuContent`, default `true`) consecutive loose items (`ContextMenuItem`, `ContextMenuCheckboxItem`, `ContextMenuRadioItem`, and `ContextMenuSub`) are automatically wrapped in a `Boxed` `ContextMenuGroup`, so they render inside a boxed container like DropdownMenu even when you do not write a group. Labels and explicit groups act as boundaries and pass through unchanged. Set `autoGroup={false}` to render children verbatim.
|
|
435
|
+
- **Max height & scrolling**: the surface caps its height at `min(maxHeight, available-height)` (where `maxHeight` defaults to `320`px and `available-height` is the space Radix has after collision handling). A taller menu scrolls vertically instead of overflowing off-screen — items and groups keep their full height rather than squishing. Pass `maxHeight={N}` to change the cap.
|
|
406
436
|
- **Checkbox / radio keep the menu open**: `ContextMenuCheckboxItem` and `ContextMenuRadioItem` call `event.preventDefault()` inside `onSelect`, stopping Radix's default auto-close so users can toggle multiple options in one pass.
|
|
407
437
|
- **Open-only animation**: only the open (enter) state animates (`fade-in`). There is intentionally no exit animation — holding the old DOM node during close breaks close/reposition on a second right-click, so it is omitted to keep repositioning reliable.
|
|
408
438
|
- **Submenus and RTL**: nested `ContextMenuSub` / `ContextMenuSubTrigger` / `ContextMenuSubContent` are supported, and `dir="rtl"` on the Root mirrors the layout (including the submenu chevron).
|
|
@@ -148,7 +148,7 @@ render `DataViewsConfigPanel` yourself (example above) or extend the layout
|
|
|
148
148
|
| Saved View | `savedViews` / `activeSavedView` | Radio list + "Save a New View" button. |
|
|
149
149
|
| Table Columns | `config.tableColumns` | Green `Switch` per column toggles `visible`; rows are drag-reorderable (HTML5 DnD) and patch `order`. |
|
|
150
150
|
| Default Sort | `config.sortBy` | Single-choice radio; selecting sets `sortBy`. Direction stays on `config.sortOrder`. |
|
|
151
|
-
| Filters tab | `filterState` + `fields` | Renders `FilterPanel` restyled full-width/transparent. |
|
|
151
|
+
| Filters tab | `filterState` + `fields` | Renders `FilterPanel` restyled full-width/transparent. Categorical fields render as checkbox/radio lists, or a `SearchableSelect` dropdown when a field sets `filterVariant: "searchable-select"`. |
|
|
152
152
|
|
|
153
153
|
## Internal: PanelControls
|
|
154
154
|
|
|
@@ -173,8 +173,8 @@ Common changes and where to make them:
|
|
|
173
173
|
|---|---|
|
|
174
174
|
| Make Saved View work through `DataViewsLayout` | Add `savedViews` / `activeSavedView` / `onSavedViewChange` / `onSaveNewView` to `DataViewsLayoutProps`, hold them in layout state (or accept from the host), and forward them to `<DataViewsConfigPanel>` at its render site in `DataViewsLayout.tsx`. |
|
|
175
175
|
| Add a new Config section | Add a new block inside the Config. tab in `DataViewsConfigPanel.tsx`, backed by a `config.*` field so `onConfigChange` persists it. |
|
|
176
|
-
| Restyle a radio/toggle | Edit `PanelControls.tsx`. Keep
|
|
177
|
-
| Change the dark chrome | The root forces `data-theme="dark"` and uses hardcoded hex (`#1C1D1F`, `#252729`, `#005ECC`, `#626467`, `#0AC713`). These are intentional Figma values, not tokens. |
|
|
176
|
+
| Restyle a radio/toggle | Edit the radio ring in `DataViewRadio.tsx` (the `RadioRow` in `PanelControls.tsx` just wraps it) or the switch in `PanelControls.tsx`. Keep the hardcoded hex matching the Figma spec; do not swap in the shared `Radio`/`Label` (they impose theming/layout that fights the dark panel — this was deliberate). |
|
|
177
|
+
| Change the dark chrome | The root forces `data-theme="dark"` and uses hardcoded hex (`#1C1D1F`, `#252729`, `#005ECC`, `#0075FF`, `#626467`, `#0AC713`). These are intentional Figma values, not tokens — the radio/checkbox rings hardcode them (`#626467` border, `rgba(255,255,255,0.05)` fill, `#0075FF` selected) so the panel always renders dark regardless of host `data-theme`. |
|
|
178
178
|
|
|
179
179
|
After any change to the panel docs or component, update this file and rebuild
|
|
180
180
|
the MCP server (`cd mcp && pnpm build`) so the docs the server serves stay in
|
|
@@ -176,7 +176,11 @@ Inbox auto-detects `isRead`, `isStarred`, `hasAttachment`, `priority`. Override
|
|
|
176
176
|
| `type` | `FieldType` | Renderer key (see below). Auto-inferred if omitted. |
|
|
177
177
|
| `visible` | `boolean` | Show in cells. Default `true`. |
|
|
178
178
|
| `order` | `number` | Display order. |
|
|
179
|
-
| `filterable` | `boolean` | Surface this field in the filter panel. |
|
|
179
|
+
| `filterable` | `boolean` | Surface this field in the filter panel. The control adapts to the field `type`: categorical fields render checkboxes/radios (or a searchable dropdown via `filterVariant`), numeric fields a range slider, and **date / date-format fields a From + To pair of Glare `DatePicker`s** (two single-date pickers bounding the range). Set `false` to explicitly exclude a field the panel would otherwise auto-detect (e.g. an `id` or `name` with few unique values). |
|
|
180
|
+
| `filterLabel` | `string` | Override the label shown above this field's filter (defaults to `label`). |
|
|
181
|
+
| `filterMode` | `"single" \| "multi"` | Categorical selection mode. `"multi"` (default) renders checkboxes; `"single"` renders radios. |
|
|
182
|
+
| `filterVariant` | `"checkbox" \| "searchable-select"` | Categorical control style. `"checkbox"` (default) is the inline checkbox/radio list; `"searchable-select"` renders a single-select `SearchableSelect` dropdown — useful when a field has many options. Implies single-select. |
|
|
183
|
+
| `filterOptions` | `string[] \| { label: string; value: string }[]` | Explicit option list for the categorical filter (otherwise options are collected from the data). |
|
|
180
184
|
| `variants` | `Record<string, BadgeVariant>` | For `enum-badge`: per-value color map. |
|
|
181
185
|
| `currency` | `string \| CurrencyOptions` | For `currency`: ISO code or `{ symbol, locale, decimals, code }`. |
|
|
182
186
|
| `thresholds` | `[number, number]` | For `progress-bar`: warning/ok thresholds. |
|
|
@@ -188,6 +192,8 @@ Inbox auto-detects `isRead`, `isStarred`, `hasAttachment`, `priority`. Override
|
|
|
188
192
|
|
|
189
193
|
`text` · `number` · `date` · `date-format` · `boolean` · `currency` · `number-format` · `enum-badge` · `badge-array` · `progress-bar` · `star-rating` · `icon-text` · `two-line` · `avatar` · `link` · `image` · `hidden`
|
|
190
194
|
|
|
195
|
+
> **`hidden` vs `filterable: false`** — use `type: "hidden"` to drop a field from the UI **entirely** (no column, no column-toggle in the config panel, no filter) while it stays in the data for row identity — e.g. an `id` you key rows by but never want shown. Use `filterable: false` to keep a field as a **column** but remove only its **filter**.
|
|
196
|
+
|
|
191
197
|
### `BadgeVariant`
|
|
192
198
|
|
|
193
199
|
`green` · `greenLight` · `cocktailGreen` · `yellow` · `redOrange` · `redLight` · `rose` · `purple` · `bluePurple` · `blue` · `navy` · `gray` · `highlight`
|
|
@@ -299,6 +299,32 @@ function Example() {
|
|
|
299
299
|
}
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
+
### Long Menu (max height + scroll)
|
|
303
|
+
|
|
304
|
+
Tall menus scroll instead of overflowing off-screen. The surface caps at `maxHeight` (default `320`px) and never exceeds the space available after collision handling. Pass `maxHeight` to change the cap.
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel } from '@torch-ui/components'
|
|
308
|
+
import { Button } from '@torch-ui/components'
|
|
309
|
+
|
|
310
|
+
function LongMenu() {
|
|
311
|
+
return (
|
|
312
|
+
<DropdownMenu>
|
|
313
|
+
<DropdownMenuTrigger asChild>
|
|
314
|
+
<Button variant="BorderStyle">Jump to section</Button>
|
|
315
|
+
</DropdownMenuTrigger>
|
|
316
|
+
{/* Cap the surface at 240px — the rest scrolls. */}
|
|
317
|
+
<DropdownMenuContent align="start" maxHeight={240}>
|
|
318
|
+
<DropdownMenuLabel>Sections</DropdownMenuLabel>
|
|
319
|
+
{Array.from({ length: 20 }, (_, i) => (
|
|
320
|
+
<DropdownMenuItem key={i}>Section {i + 1}</DropdownMenuItem>
|
|
321
|
+
))}
|
|
322
|
+
</DropdownMenuContent>
|
|
323
|
+
</DropdownMenu>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
302
328
|
## API Reference
|
|
303
329
|
|
|
304
330
|
### DropdownMenu (Root)
|
|
@@ -326,6 +352,7 @@ function Example() {
|
|
|
326
352
|
| `sideOffset` | `number` | `4` | Distance from trigger |
|
|
327
353
|
| `collisionPadding` | `number` | `8` | Gap kept from viewport edges when flipping/shifting |
|
|
328
354
|
| `align` | `'start' \| 'center' \| 'end'` | `'center'` | Alignment (inherited from Radix) |
|
|
355
|
+
| `maxHeight` | `number` | `320` | Max height (px) of the surface before it scrolls. Capped at `min(maxHeight, available-height)` so the menu never overflows off-screen |
|
|
329
356
|
| `autoGroup` | `boolean` | `true` | Auto-wrap loose items in boxed groups |
|
|
330
357
|
|
|
331
358
|
### DropdownMenuItem
|
|
@@ -426,6 +453,7 @@ interface DropdownMenuContentProps {
|
|
|
426
453
|
sideOffset?: number
|
|
427
454
|
collisionPadding?: number
|
|
428
455
|
align?: 'start' | 'center' | 'end'
|
|
456
|
+
maxHeight?: number // default 320 — surface scrolls past this
|
|
429
457
|
autoGroup?: boolean
|
|
430
458
|
}
|
|
431
459
|
|