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.
@@ -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";
@@ -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 values 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`, `#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