torch-glare 2.1.7 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/lib/components/BadgeField.tsx +143 -17
- package/apps/lib/components/ContextMenu.tsx +524 -0
- package/apps/lib/components/DropdownMenu.tsx +254 -102
- package/apps/lib/components/SearchableSelect.tsx +308 -0
- package/apps/lib/components/SearchableTable.tsx +363 -0
- package/apps/lib/components/Table.tsx +6 -6
- package/docs/components/badge-field.md +95 -91
- package/docs/components/context-menu.md +458 -0
- package/docs/components/dropdown-menu.md +68 -58
- package/docs/components/searchable-select.md +359 -0
- package/docs/components/searchable-table.md +419 -0
- package/docs/reference/tailwind-plugins.md +21 -1
- package/docs/tutorials/getting-started.md +15 -1
- package/package.json +1 -1
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: DropdownMenu
|
|
3
|
-
description: Comprehensive dropdown menu component with rich features including sub-menus, checkboxes, radio groups, and keyboard navigation
|
|
3
|
+
description: Comprehensive dropdown menu component with rich features including sub-menus, checkboxes, radio groups, auto-grouping, and keyboard navigation
|
|
4
4
|
group: Overlays & Dialogs
|
|
5
|
-
keywords: [dropdown-menu, menu,
|
|
5
|
+
keywords: [dropdown-menu, menu, radix-ui, submenu, checkbox, radio, auto-group]
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# DropdownMenu
|
|
9
9
|
|
|
10
|
-
> A feature-rich dropdown menu component with support for nested submenus, checkbox items, radio groups,
|
|
10
|
+
> A feature-rich dropdown menu component with support for nested submenus, checkbox items, radio groups, auto-grouped boxed sections, and keyboard shortcuts. Perfect for application menus and complex action lists. For right-click menus, see [ContextMenu](./context-menu.md).
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -27,15 +27,17 @@ import {
|
|
|
27
27
|
DropdownMenuRadioGroup,
|
|
28
28
|
DropdownMenuRadioItem,
|
|
29
29
|
DropdownMenuLabel,
|
|
30
|
-
DropdownMenuSeparator,
|
|
31
30
|
DropdownMenuShortcut,
|
|
32
31
|
DropdownMenuSub,
|
|
33
32
|
DropdownMenuSubTrigger,
|
|
34
33
|
DropdownMenuSubContent,
|
|
35
34
|
DropdownMenuGroup,
|
|
35
|
+
DropdownMenuPortal,
|
|
36
36
|
} from '@torch-ui/components'
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
> **Auto-grouping:** Loose items placed directly in `DropdownMenuContent` (or `DropdownMenuSubContent`) are automatically wrapped in a boxed group, so they render inside a rounded container without you writing `DropdownMenuGroup` yourself. A `DropdownMenuLabel`, an explicit `DropdownMenuGroup`, or a `DropdownMenuRadioGroup` acts as a boundary that starts a new box. Disable this with `autoGroup={false}` on the content. (There is no `DropdownMenuSeparator` — separation comes from labels and the boxed groups.)
|
|
40
|
+
|
|
39
41
|
## Quick Examples
|
|
40
42
|
|
|
41
43
|
### Basic Menu
|
|
@@ -122,10 +124,12 @@ function ShortcutMenu() {
|
|
|
122
124
|
}
|
|
123
125
|
```
|
|
124
126
|
|
|
125
|
-
### With Labels and
|
|
127
|
+
### With Labels and Grouping
|
|
128
|
+
|
|
129
|
+
Labels act as section boundaries. The loose items between labels are automatically wrapped in boxed groups — no separator component needed.
|
|
126
130
|
|
|
127
131
|
```typescript
|
|
128
|
-
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel
|
|
132
|
+
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel } from '@torch-ui/components'
|
|
129
133
|
|
|
130
134
|
function OrganizedMenu() {
|
|
131
135
|
return (
|
|
@@ -138,14 +142,10 @@ function OrganizedMenu() {
|
|
|
138
142
|
<DropdownMenuItem>Profile</DropdownMenuItem>
|
|
139
143
|
<DropdownMenuItem>Billing</DropdownMenuItem>
|
|
140
144
|
|
|
141
|
-
<DropdownMenuSeparator />
|
|
142
|
-
|
|
143
145
|
<DropdownMenuLabel>Settings</DropdownMenuLabel>
|
|
144
146
|
<DropdownMenuItem>Preferences</DropdownMenuItem>
|
|
145
147
|
<DropdownMenuItem>Keyboard Shortcuts</DropdownMenuItem>
|
|
146
148
|
|
|
147
|
-
<DropdownMenuSeparator />
|
|
148
|
-
|
|
149
149
|
<DropdownMenuItem variant="Negative">Logout</DropdownMenuItem>
|
|
150
150
|
</DropdownMenuContent>
|
|
151
151
|
</DropdownMenu>
|
|
@@ -155,8 +155,10 @@ function OrganizedMenu() {
|
|
|
155
155
|
|
|
156
156
|
### With Checkboxes
|
|
157
157
|
|
|
158
|
+
Checkbox and radio items keep the menu **open** when toggled (the built-in `onSelect` calls `preventDefault`), so users can change several options without the menu closing each time.
|
|
159
|
+
|
|
158
160
|
```typescript
|
|
159
|
-
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem
|
|
161
|
+
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem } from '@torch-ui/components'
|
|
160
162
|
import { useState } from 'react'
|
|
161
163
|
|
|
162
164
|
function CheckboxMenu() {
|
|
@@ -274,47 +276,25 @@ function DisabledMenu() {
|
|
|
274
276
|
}
|
|
275
277
|
```
|
|
276
278
|
|
|
277
|
-
###
|
|
279
|
+
### Right-click Menu
|
|
278
280
|
|
|
279
|
-
|
|
280
|
-
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@torch-ui/components'
|
|
281
|
-
|
|
282
|
-
function SystemMenu() {
|
|
283
|
-
return (
|
|
284
|
-
<DropdownMenu>
|
|
285
|
-
<DropdownMenuTrigger asChild>
|
|
286
|
-
<button>System</button>
|
|
287
|
-
</DropdownMenuTrigger>
|
|
288
|
-
<DropdownMenuContent variant="SystemStyle">
|
|
289
|
-
<DropdownMenuItem variant="SystemStyle">Settings</DropdownMenuItem>
|
|
290
|
-
<DropdownMenuItem variant="SystemStyle">About</DropdownMenuItem>
|
|
291
|
-
<DropdownMenuItem variant="SystemStyle">Help</DropdownMenuItem>
|
|
292
|
-
</DropdownMenuContent>
|
|
293
|
-
</DropdownMenu>
|
|
294
|
-
)
|
|
295
|
-
}
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
### Context Menu Pattern
|
|
281
|
+
For a true right-click (context) menu, use the dedicated [ContextMenu](./context-menu.md) component instead of DropdownMenu — it opens at the pointer on right-click / long-press.
|
|
299
282
|
|
|
300
283
|
```typescript
|
|
301
|
-
import {
|
|
284
|
+
import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem } from '@torch-ui/components'
|
|
302
285
|
|
|
303
|
-
function
|
|
286
|
+
function Example() {
|
|
304
287
|
return (
|
|
305
|
-
<
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
<DropdownMenuItem>Properties</DropdownMenuItem>
|
|
316
|
-
</DropdownMenuContent>
|
|
317
|
-
</DropdownMenu>
|
|
288
|
+
<ContextMenu>
|
|
289
|
+
<ContextMenuTrigger className="rounded-md border border-dashed p-8">
|
|
290
|
+
Right-click here
|
|
291
|
+
</ContextMenuTrigger>
|
|
292
|
+
<ContextMenuContent>
|
|
293
|
+
<ContextMenuItem>View</ContextMenuItem>
|
|
294
|
+
<ContextMenuItem>Edit</ContextMenuItem>
|
|
295
|
+
<ContextMenuItem variant="Negative">Delete</ContextMenuItem>
|
|
296
|
+
</ContextMenuContent>
|
|
297
|
+
</ContextMenu>
|
|
318
298
|
)
|
|
319
299
|
}
|
|
320
300
|
```
|
|
@@ -340,18 +320,21 @@ function ContextMenu({ x, y }: { x: number; y: number }) {
|
|
|
340
320
|
|
|
341
321
|
| Prop | Type | Default | Description |
|
|
342
322
|
|------|------|---------|-------------|
|
|
343
|
-
| `variant` | `'
|
|
323
|
+
| `variant` | `'PresentationStyle'` | `'PresentationStyle'` | Visual style variant |
|
|
344
324
|
| `theme` | `'dark' \| 'light' \| 'default'` | - | Theme variant |
|
|
345
325
|
| `className` | `string` | - | Additional CSS classes |
|
|
346
326
|
| `sideOffset` | `number` | `4` | Distance from trigger |
|
|
347
|
-
| `
|
|
327
|
+
| `collisionPadding` | `number` | `8` | Gap kept from viewport edges when flipping/shifting |
|
|
328
|
+
| `align` | `'start' \| 'center' \| 'end'` | `'center'` | Alignment (inherited from Radix) |
|
|
329
|
+
| `autoGroup` | `boolean` | `true` | Auto-wrap loose items in boxed groups |
|
|
348
330
|
|
|
349
331
|
### DropdownMenuItem
|
|
350
332
|
|
|
351
333
|
| Prop | Type | Default | Description |
|
|
352
334
|
|------|------|---------|-------------|
|
|
353
|
-
| `variant` | `'Default' \| '
|
|
335
|
+
| `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Item style variant |
|
|
354
336
|
| `size` | `'S' \| 'M'` | `'M'` | Item size |
|
|
337
|
+
| `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
|
|
355
338
|
| `disabled` | `boolean` | `false` | Disabled state |
|
|
356
339
|
| `active` | `boolean` | `false` | Active state |
|
|
357
340
|
| `onSelect` | `(event) => void` | - | Select handler |
|
|
@@ -362,7 +345,8 @@ function ContextMenu({ x, y }: { x: number; y: number }) {
|
|
|
362
345
|
|------|------|---------|-------------|
|
|
363
346
|
| `checked` | `boolean \| 'indeterminate'` | `false` | Checked state |
|
|
364
347
|
| `onCheckedChange` | `(checked: boolean) => void` | - | Change handler |
|
|
365
|
-
| `variant` | `'Default' \| '
|
|
348
|
+
| `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
|
|
349
|
+
| `size` | `'S' \| 'M'` | `'M'` | Item size |
|
|
366
350
|
|
|
367
351
|
### DropdownMenuRadioGroup
|
|
368
352
|
|
|
@@ -376,18 +360,38 @@ function ContextMenu({ x, y }: { x: number; y: number }) {
|
|
|
376
360
|
| Prop | Type | Default | Description |
|
|
377
361
|
|------|------|---------|-------------|
|
|
378
362
|
| `value` | `string` | Required | Radio option value |
|
|
379
|
-
| `variant` | `'Default' \| '
|
|
363
|
+
| `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
|
|
364
|
+
| `size` | `'S' \| 'M'` | `'M'` | Item size |
|
|
365
|
+
|
|
366
|
+
### DropdownMenuSubTrigger
|
|
367
|
+
|
|
368
|
+
| Prop | Type | Default | Description |
|
|
369
|
+
|------|------|---------|-------------|
|
|
370
|
+
| `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
|
|
371
|
+
| `size` | `'S' \| 'M'` | `'M'` | Item size |
|
|
372
|
+
| `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
|
|
373
|
+
| `className` | `string` | - | Additional CSS classes |
|
|
374
|
+
|
|
375
|
+
### DropdownMenuSubContent
|
|
376
|
+
|
|
377
|
+
| Prop | Type | Default | Description |
|
|
378
|
+
|------|------|---------|-------------|
|
|
379
|
+
| `variant` | `'PresentationStyle'` | `'PresentationStyle'` | Visual style variant |
|
|
380
|
+
| `autoGroup` | `boolean` | `true` | Auto-wrap loose items in boxed groups |
|
|
381
|
+
| `className` | `string` | - | Additional CSS classes |
|
|
380
382
|
|
|
381
383
|
### DropdownMenuLabel
|
|
382
384
|
|
|
383
385
|
| Prop | Type | Default | Description |
|
|
384
386
|
|------|------|---------|-------------|
|
|
387
|
+
| `inset` | `boolean` | `false` | Add left padding to align with items that have icons |
|
|
385
388
|
| `className` | `string` | - | Additional CSS classes |
|
|
386
389
|
|
|
387
|
-
###
|
|
390
|
+
### DropdownMenuGroup
|
|
388
391
|
|
|
389
392
|
| Prop | Type | Default | Description |
|
|
390
393
|
|------|------|---------|-------------|
|
|
394
|
+
| `variant` | `'Boxed' \| 'Plain'` | `'Boxed'` | Boxed renders a bordered container; Plain is semantic-only |
|
|
391
395
|
| `className` | `string` | - | Additional CSS classes |
|
|
392
396
|
|
|
393
397
|
### DropdownMenuShortcut
|
|
@@ -416,19 +420,22 @@ export const DropdownMenu: React.FC<DropdownMenuProps>
|
|
|
416
420
|
|
|
417
421
|
// Content
|
|
418
422
|
interface DropdownMenuContentProps {
|
|
419
|
-
variant?: '
|
|
423
|
+
variant?: 'PresentationStyle'
|
|
420
424
|
theme?: 'dark' | 'light' | 'default'
|
|
421
425
|
className?: string
|
|
422
426
|
sideOffset?: number
|
|
427
|
+
collisionPadding?: number
|
|
423
428
|
align?: 'start' | 'center' | 'end'
|
|
429
|
+
autoGroup?: boolean
|
|
424
430
|
}
|
|
425
431
|
|
|
426
432
|
export const DropdownMenuContent: React.ForwardRefExoticComponent<DropdownMenuContentProps>
|
|
427
433
|
|
|
428
434
|
// Item
|
|
429
435
|
interface DropdownMenuItemProps {
|
|
430
|
-
variant?: 'Default' | '
|
|
436
|
+
variant?: 'Default' | 'info' | 'Negative'
|
|
431
437
|
size?: 'S' | 'M'
|
|
438
|
+
inset?: boolean
|
|
432
439
|
disabled?: boolean
|
|
433
440
|
active?: boolean
|
|
434
441
|
onSelect?: (event: Event) => void
|
|
@@ -440,7 +447,8 @@ export const DropdownMenuItem: React.ForwardRefExoticComponent<DropdownMenuItemP
|
|
|
440
447
|
interface DropdownMenuCheckboxItemProps {
|
|
441
448
|
checked?: boolean | 'indeterminate'
|
|
442
449
|
onCheckedChange?: (checked: boolean) => void
|
|
443
|
-
variant?: 'Default' | '
|
|
450
|
+
variant?: 'Default' | 'info' | 'Negative'
|
|
451
|
+
size?: 'S' | 'M'
|
|
444
452
|
}
|
|
445
453
|
|
|
446
454
|
export const DropdownMenuCheckboxItem: React.ForwardRefExoticComponent<DropdownMenuCheckboxItemProps>
|
|
@@ -448,7 +456,9 @@ export const DropdownMenuCheckboxItem: React.ForwardRefExoticComponent<DropdownM
|
|
|
448
456
|
// RadioItem
|
|
449
457
|
interface DropdownMenuRadioItemProps {
|
|
450
458
|
value: string
|
|
451
|
-
variant?: 'Default' | '
|
|
459
|
+
variant?: 'Default' | 'info' | 'Negative'
|
|
460
|
+
size?: 'S' | 'M'
|
|
461
|
+
onSelect?: (event: Event) => void
|
|
452
462
|
}
|
|
453
463
|
|
|
454
464
|
export const DropdownMenuRadioItem: React.ForwardRefExoticComponent<DropdownMenuRadioItemProps>
|
|
@@ -595,7 +605,7 @@ describe('DropdownMenu', () => {
|
|
|
595
605
|
| Bundle size (minified) | ~8kb |
|
|
596
606
|
| Bundle size (gzipped) | ~3kb |
|
|
597
607
|
| Dependencies | @radix-ui/react-dropdown-menu (~15kb) |
|
|
598
|
-
| Max height |
|
|
608
|
+
| Max height | Radix available height (scrollable) |
|
|
599
609
|
| Tree-shakeable | ✅ |
|
|
600
610
|
|
|
601
611
|
## Best Practices
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: SearchableSelect
|
|
3
|
+
description: Searchable single-select combobox with focus-to-open, client or server-side filtering, and infinite-scroll pagination
|
|
4
|
+
group: Inputs
|
|
5
|
+
keywords: [searchable-select, combobox, select, search, async, infinite-scroll, pagination]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# SearchableSelect
|
|
9
|
+
|
|
10
|
+
> A searchable single-select combobox that opens on focus and filters options as you type. Renders DropdownMenu-style rows on a Popover surface so the input keeps focus while filtering. Supports static options with local filtering, or server-side search with debounced queries and infinite-scroll pagination.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @radix-ui/react-popover
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
SearchableSelect is built on the TORCH `Popover` component (which wraps `@radix-ui/react-popover`) and reuses the `Input`, `Button`, and `DropdownMenu` item styles. All imports come from `@torch-ui/components`.
|
|
19
|
+
|
|
20
|
+
## Import
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { SearchableSelect } from '@torch-ui/components'
|
|
24
|
+
import type { SearchableSelectOption } from '@torch-ui/components'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Examples
|
|
28
|
+
|
|
29
|
+
### Basic (static options)
|
|
30
|
+
|
|
31
|
+
Pass a static `options` array and control the selection with `value` / `onValueChange`. Options may include an `icon`. Filtering happens locally by default.
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { SearchableSelect } from '@torch-ui/components'
|
|
35
|
+
import type { SearchableSelectOption } from '@torch-ui/components'
|
|
36
|
+
import { useState } from 'react'
|
|
37
|
+
|
|
38
|
+
const options: SearchableSelectOption[] = [
|
|
39
|
+
{ value: 'react', label: 'React', icon: <i className="ri-reactjs-line" /> },
|
|
40
|
+
{ value: 'vue', label: 'Vue', icon: <i className="ri-vuejs-line" /> },
|
|
41
|
+
{ value: 'svelte', label: 'Svelte', icon: <i className="ri-svelte-line" /> },
|
|
42
|
+
{ value: 'angular', label: 'Angular', icon: <i className="ri-angularjs-line" /> },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
function FrameworkPicker() {
|
|
46
|
+
const [value, setValue] = useState<string | null>(null)
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<SearchableSelect
|
|
50
|
+
options={options}
|
|
51
|
+
value={value}
|
|
52
|
+
onValueChange={(next) => setValue(next)}
|
|
53
|
+
placeholder="Search frameworks…"
|
|
54
|
+
icon={<i className="ri-search-line" />}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Async server search + infinite scroll
|
|
61
|
+
|
|
62
|
+
For large or remote datasets, set `filterClientSide={false}` and drive everything from controlled props: refetch in `onSearchChange` (debounced), append pages in `onLoadMore`, and report `hasMore` / `loading`. Options are rendered as-is — the component does not filter them locally in this mode.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { SearchableSelect } from '@torch-ui/components'
|
|
66
|
+
import type { SearchableSelectOption } from '@torch-ui/components'
|
|
67
|
+
import { useEffect, useState } from 'react'
|
|
68
|
+
|
|
69
|
+
const PAGE_SIZE = 20
|
|
70
|
+
|
|
71
|
+
function UserPicker() {
|
|
72
|
+
const [value, setValue] = useState<string | null>(null)
|
|
73
|
+
const [options, setOptions] = useState<SearchableSelectOption[]>([])
|
|
74
|
+
const [query, setQuery] = useState('')
|
|
75
|
+
const [page, setPage] = useState(1)
|
|
76
|
+
const [hasMore, setHasMore] = useState(true)
|
|
77
|
+
const [loading, setLoading] = useState(false)
|
|
78
|
+
|
|
79
|
+
// Fetch whenever the query or page changes.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false
|
|
82
|
+
setLoading(true)
|
|
83
|
+
|
|
84
|
+
fetch(`/api/users?q=${encodeURIComponent(query)}&page=${page}&size=${PAGE_SIZE}`)
|
|
85
|
+
.then((res) => res.json())
|
|
86
|
+
.then((data: { id: string; name: string }[]) => {
|
|
87
|
+
if (cancelled) return
|
|
88
|
+
const next = data.map((u) => ({ value: u.id, label: u.name }))
|
|
89
|
+
// Replace on a fresh query (page 1), append on subsequent pages.
|
|
90
|
+
setOptions((prev) => (page === 1 ? next : [...prev, ...next]))
|
|
91
|
+
setHasMore(data.length === PAGE_SIZE)
|
|
92
|
+
})
|
|
93
|
+
.finally(() => {
|
|
94
|
+
if (!cancelled) setLoading(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
cancelled = true
|
|
99
|
+
}
|
|
100
|
+
}, [query, page])
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<SearchableSelect
|
|
104
|
+
options={options}
|
|
105
|
+
value={value}
|
|
106
|
+
onValueChange={(next) => setValue(next)}
|
|
107
|
+
placeholder="Search users…"
|
|
108
|
+
filterClientSide={false}
|
|
109
|
+
onSearchChange={(q) => {
|
|
110
|
+
// New search: reset to the first page.
|
|
111
|
+
setQuery(q)
|
|
112
|
+
setPage(1)
|
|
113
|
+
}}
|
|
114
|
+
hasMore={hasMore}
|
|
115
|
+
loading={loading}
|
|
116
|
+
onLoadMore={() => setPage((p) => p + 1)}
|
|
117
|
+
searchDebounceMs={300}
|
|
118
|
+
/>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Capping visible rows (`maxVisibleItems`)
|
|
124
|
+
|
|
125
|
+
The list shows up to `maxVisibleItems` rows (default `5`) before it scrolls internally.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { SearchableSelect } from '@torch-ui/components'
|
|
129
|
+
|
|
130
|
+
function CompactList({ options, value, onValueChange }) {
|
|
131
|
+
return (
|
|
132
|
+
<SearchableSelect
|
|
133
|
+
options={options}
|
|
134
|
+
value={value}
|
|
135
|
+
onValueChange={onValueChange}
|
|
136
|
+
maxVisibleItems={3}
|
|
137
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### RTL support
|
|
143
|
+
|
|
144
|
+
Pass `dir="rtl"` to lay out the input, chevron, and dropdown right-to-left.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
import { SearchableSelect } from '@torch-ui/components'
|
|
148
|
+
|
|
149
|
+
function ArabicPicker({ options, value, onValueChange }) {
|
|
150
|
+
return (
|
|
151
|
+
<SearchableSelect
|
|
152
|
+
dir="rtl"
|
|
153
|
+
options={options}
|
|
154
|
+
value={value}
|
|
155
|
+
onValueChange={onValueChange}
|
|
156
|
+
placeholder="ابحث…"
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## API Reference
|
|
163
|
+
|
|
164
|
+
### SearchableSelect
|
|
165
|
+
|
|
166
|
+
| Prop | Type | Default | Description |
|
|
167
|
+
|------|------|---------|-------------|
|
|
168
|
+
| `options` | `SearchableSelectOption[]` | Required | The list of selectable options. In server mode (`filterClientSide={false}`) these are rendered as-is. |
|
|
169
|
+
| `value` | `string \| null` | - | Controlled selected value. The matching option's label is shown as solid text in the input. |
|
|
170
|
+
| `onValueChange` | `(value: string, option: SearchableSelectOption) => void` | - | Called when an option is selected. Receives the value and the full option object. |
|
|
171
|
+
| `placeholder` | `string` | `'Search…'` | Placeholder text for the search input. |
|
|
172
|
+
| `size` | `'XS' \| 'S' \| 'M'` | `'M'` | Field size. |
|
|
173
|
+
| `variant` | `'PresentationStyle'` | `'PresentationStyle'` | Visual style variant for the field and dropdown surface. |
|
|
174
|
+
| `icon` | `ReactNode` | - | Optional leading icon rendered inside the field. |
|
|
175
|
+
| `theme` | `'dark' \| 'light' \| 'default'` | - | Theme variant, applied via `data-theme`. |
|
|
176
|
+
| `dir` | `string` | - | Text direction (e.g. `'rtl'`) for the field and dropdown. |
|
|
177
|
+
| `className` | `string` | - | Additional CSS classes for the field group. |
|
|
178
|
+
| `filterClientSide` | `boolean` | `true` | When `true`, filters `options` locally by label. Set `false` for server-side search — `options` are rendered as-is and refetched via `onSearchChange`. |
|
|
179
|
+
| `onSearchChange` | `(query: string) => void` | - | Called (debounced) with the trimmed query as the user types. Refetch your data here for server-side search. |
|
|
180
|
+
| `searchDebounceMs` | `number` | `300` | Debounce delay (ms) before `onSearchChange` fires. |
|
|
181
|
+
| `hasMore` | `boolean` | `false` | Whether more pages are available; gates the infinite-scroll loader. |
|
|
182
|
+
| `loading` | `boolean` | `false` | Whether a fetch is in flight; shows a loading row and blocks `onLoadMore`. |
|
|
183
|
+
| `onLoadMore` | `() => void` | - | Called when the scroll viewport nears the bottom and `hasMore && !loading`. |
|
|
184
|
+
| `maxVisibleItems` | `number` | `5` | Maximum rows visible before the list scrolls internally. |
|
|
185
|
+
|
|
186
|
+
### SearchableSelectOption
|
|
187
|
+
|
|
188
|
+
| Prop | Type | Default | Description |
|
|
189
|
+
|------|------|---------|-------------|
|
|
190
|
+
| `value` | `string` | Required | Unique value for the option; matched against the `value` prop. |
|
|
191
|
+
| `label` | `string` | Required | Display text; also used for client-side label filtering. |
|
|
192
|
+
| `icon` | `ReactNode` | - | Optional leading icon rendered in the row. |
|
|
193
|
+
|
|
194
|
+
## TypeScript
|
|
195
|
+
|
|
196
|
+
### Type Definitions
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { ReactNode } from 'react'
|
|
200
|
+
|
|
201
|
+
export interface SearchableSelectOption {
|
|
202
|
+
value: string
|
|
203
|
+
label: string
|
|
204
|
+
icon?: ReactNode
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface SearchableSelectProps {
|
|
208
|
+
options: SearchableSelectOption[]
|
|
209
|
+
value?: string | null
|
|
210
|
+
onValueChange?: (value: string, option: SearchableSelectOption) => void
|
|
211
|
+
placeholder?: string
|
|
212
|
+
size?: 'XS' | 'S' | 'M'
|
|
213
|
+
variant?: 'PresentationStyle'
|
|
214
|
+
icon?: ReactNode
|
|
215
|
+
theme?: 'dark' | 'light' | 'default'
|
|
216
|
+
dir?: string
|
|
217
|
+
className?: string
|
|
218
|
+
|
|
219
|
+
// Async / backend pagination (all optional; static `options` still work)
|
|
220
|
+
filterClientSide?: boolean
|
|
221
|
+
onSearchChange?: (query: string) => void
|
|
222
|
+
searchDebounceMs?: number
|
|
223
|
+
hasMore?: boolean
|
|
224
|
+
loading?: boolean
|
|
225
|
+
onLoadMore?: () => void
|
|
226
|
+
maxVisibleItems?: number
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function SearchableSelect(props: SearchableSelectProps): JSX.Element
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Common Patterns
|
|
233
|
+
|
|
234
|
+
### With React Query (`useInfiniteQuery`)
|
|
235
|
+
|
|
236
|
+
React Query's `useInfiniteQuery` maps cleanly onto the controlled async props. Track the debounced query in state, flatten pages into `options`, and wire `fetchNextPage`/`hasNextPage`/`isFetching` to `onLoadMore`/`hasMore`/`loading`.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
import { SearchableSelect } from '@torch-ui/components'
|
|
240
|
+
import type { SearchableSelectOption } from '@torch-ui/components'
|
|
241
|
+
import { useInfiniteQuery } from '@tanstack/react-query'
|
|
242
|
+
import { useMemo, useState } from 'react'
|
|
243
|
+
|
|
244
|
+
function UserPicker() {
|
|
245
|
+
const [value, setValue] = useState<string | null>(null)
|
|
246
|
+
const [query, setQuery] = useState('')
|
|
247
|
+
|
|
248
|
+
const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery({
|
|
249
|
+
queryKey: ['users', query],
|
|
250
|
+
queryFn: ({ pageParam = 1 }) =>
|
|
251
|
+
fetch(`/api/users?q=${query}&page=${pageParam}`).then((r) => r.json()),
|
|
252
|
+
getNextPageParam: (lastPage, pages) =>
|
|
253
|
+
lastPage.length ? pages.length + 1 : undefined,
|
|
254
|
+
initialPageParam: 1,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const options: SearchableSelectOption[] = useMemo(
|
|
258
|
+
() =>
|
|
259
|
+
(data?.pages.flat() ?? []).map((u: { id: string; name: string }) => ({
|
|
260
|
+
value: u.id,
|
|
261
|
+
label: u.name,
|
|
262
|
+
})),
|
|
263
|
+
[data]
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<SearchableSelect
|
|
268
|
+
options={options}
|
|
269
|
+
value={value}
|
|
270
|
+
onValueChange={setValue}
|
|
271
|
+
filterClientSide={false}
|
|
272
|
+
onSearchChange={setQuery}
|
|
273
|
+
hasMore={Boolean(hasNextPage)}
|
|
274
|
+
loading={isFetching}
|
|
275
|
+
onLoadMore={() => fetchNextPage()}
|
|
276
|
+
/>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### With SWR (`useSWRInfinite`)
|
|
282
|
+
|
|
283
|
+
The same shape works with SWR's `useSWRInfinite` — increment `size` in `onLoadMore`, flatten the page array into `options`, and derive `hasMore` from the last page length.
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
import { SearchableSelect } from '@torch-ui/components'
|
|
287
|
+
import useSWRInfinite from 'swr/infinite'
|
|
288
|
+
import { useState } from 'react'
|
|
289
|
+
|
|
290
|
+
const PAGE_SIZE = 20
|
|
291
|
+
|
|
292
|
+
function UserPicker() {
|
|
293
|
+
const [value, setValue] = useState<string | null>(null)
|
|
294
|
+
const [query, setQuery] = useState('')
|
|
295
|
+
|
|
296
|
+
const { data, size, setSize, isValidating } = useSWRInfinite(
|
|
297
|
+
(index) => `/api/users?q=${query}&page=${index + 1}&size=${PAGE_SIZE}`,
|
|
298
|
+
(url) => fetch(url).then((r) => r.json())
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
const pages = data ?? []
|
|
302
|
+
const options = pages
|
|
303
|
+
.flat()
|
|
304
|
+
.map((u: { id: string; name: string }) => ({ value: u.id, label: u.name }))
|
|
305
|
+
const hasMore = pages.length > 0 && pages[pages.length - 1].length === PAGE_SIZE
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<SearchableSelect
|
|
309
|
+
options={options}
|
|
310
|
+
value={value}
|
|
311
|
+
onValueChange={setValue}
|
|
312
|
+
filterClientSide={false}
|
|
313
|
+
onSearchChange={(q) => {
|
|
314
|
+
setQuery(q)
|
|
315
|
+
setSize(1)
|
|
316
|
+
}}
|
|
317
|
+
hasMore={hasMore}
|
|
318
|
+
loading={isValidating}
|
|
319
|
+
onLoadMore={() => setSize(size + 1)}
|
|
320
|
+
/>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Accessibility
|
|
326
|
+
|
|
327
|
+
- **Focus opens the dropdown**: focusing the input opens the list and clears any in-progress search so the user can type freely.
|
|
328
|
+
- **Type to filter**: typing updates the query. With `filterClientSide={true}` (default) options filter locally by label; otherwise the debounced `onSearchChange` drives a server refetch.
|
|
329
|
+
- **Chevron toggle**: a boxed icon `Button` toggles the dropdown open/closed and flips (rotates 180°) to reflect state. It is `tabIndex={-1}` and exposes a dynamic `aria-label` of `"Open"` / `"Close"`.
|
|
330
|
+
- **Single-select**: clicking a row selects it, closes the dropdown, and shows the option's label as solid text in the input. The selected row is marked with a check icon and `data-highlighted`.
|
|
331
|
+
- **Infinite scroll**: the scroll viewport calls `onLoadMore` when it nears the bottom (within ~80px) and `hasMore && !loading`. A `LoadingIcon` row is shown while `loading` is true.
|
|
332
|
+
- **Empty state**: when no options match and nothing is loading, a "No results found" message is shown.
|
|
333
|
+
- **Rows match DropdownMenu items**: option rows reuse `MenuItemStyles`, so they look and behave like `DropdownMenuItem` entries.
|
|
334
|
+
|
|
335
|
+
## Best Practices
|
|
336
|
+
|
|
337
|
+
1. **Always control `value`** — pass `value` and `onValueChange` so the input can display the selected label.
|
|
338
|
+
```typescript
|
|
339
|
+
<SearchableSelect value={value} onValueChange={setValue} options={options} />
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
2. **Disable client filtering for server search** — set `filterClientSide={false}` whenever you refetch in `onSearchChange`, otherwise local filtering will hide server results.
|
|
343
|
+
|
|
344
|
+
3. **Reset to the first page on a new query** — in `onSearchChange`, set your page back to `1` (or `setSize(1)`) before refetching so paginated results don't mix searches.
|
|
345
|
+
|
|
346
|
+
4. **Report `loading` accurately** — `onLoadMore` is blocked while `loading` is `true`, which prevents duplicate page fetches during scroll.
|
|
347
|
+
|
|
348
|
+
5. **Gate pagination with `hasMore`** — only set `hasMore` when the backend confirms another page exists, so the loader stops at the end of the list.
|
|
349
|
+
|
|
350
|
+
6. **Tune `searchDebounceMs`** — increase it for expensive endpoints to reduce request volume; lower it for snappy local-backed APIs.
|
|
351
|
+
|
|
352
|
+
7. **Cap the visible list with `maxVisibleItems`** — keep the dropdown compact for long lists; the rest scrolls internally.
|
|
353
|
+
|
|
354
|
+
## Related Components
|
|
355
|
+
|
|
356
|
+
- [Select](./select.md) - Standard form select field
|
|
357
|
+
- [SearchableTable](./searchable-table.md) - Searchable table with the same focus-to-open behavior
|
|
358
|
+
- [DropdownMenu](./dropdown-menu.md) - Menu surface and item styling reused by the rows
|
|
359
|
+
- [BadgeField](./badge-field.md) - Multi-value tag/badge input field
|