torch-glare 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, context-menu, radix-ui, submenu, checkbox]
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, separators, and keyboard shortcuts. Perfect for application menus, context menus, and complex action lists.
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,7 +27,6 @@ import {
27
27
  DropdownMenuRadioGroup,
28
28
  DropdownMenuRadioItem,
29
29
  DropdownMenuLabel,
30
- DropdownMenuSeparator,
31
30
  DropdownMenuShortcut,
32
31
  DropdownMenuSub,
33
32
  DropdownMenuSubTrigger,
@@ -36,6 +35,8 @@ import {
36
35
  } from '@torch-ui/components'
37
36
  ```
38
37
 
38
+ > **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.)
39
+
39
40
  ## Quick Examples
40
41
 
41
42
  ### Basic Menu
@@ -122,10 +123,12 @@ function ShortcutMenu() {
122
123
  }
123
124
  ```
124
125
 
125
- ### With Labels and Separators
126
+ ### With Labels and Grouping
127
+
128
+ Labels act as section boundaries. The loose items between labels are automatically wrapped in boxed groups — no separator component needed.
126
129
 
127
130
  ```typescript
128
- import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@torch-ui/components'
131
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel } from '@torch-ui/components'
129
132
 
130
133
  function OrganizedMenu() {
131
134
  return (
@@ -138,14 +141,10 @@ function OrganizedMenu() {
138
141
  <DropdownMenuItem>Profile</DropdownMenuItem>
139
142
  <DropdownMenuItem>Billing</DropdownMenuItem>
140
143
 
141
- <DropdownMenuSeparator />
142
-
143
144
  <DropdownMenuLabel>Settings</DropdownMenuLabel>
144
145
  <DropdownMenuItem>Preferences</DropdownMenuItem>
145
146
  <DropdownMenuItem>Keyboard Shortcuts</DropdownMenuItem>
146
147
 
147
- <DropdownMenuSeparator />
148
-
149
148
  <DropdownMenuItem variant="Negative">Logout</DropdownMenuItem>
150
149
  </DropdownMenuContent>
151
150
  </DropdownMenu>
@@ -155,8 +154,10 @@ function OrganizedMenu() {
155
154
 
156
155
  ### With Checkboxes
157
156
 
157
+ 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.
158
+
158
159
  ```typescript
159
- import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@torch-ui/components'
160
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem } from '@torch-ui/components'
160
161
  import { useState } from 'react'
161
162
 
162
163
  function CheckboxMenu() {
@@ -295,26 +296,25 @@ function SystemMenu() {
295
296
  }
296
297
  ```
297
298
 
298
- ### Context Menu Pattern
299
+ ### Right-click Menu
300
+
301
+ 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
302
 
300
303
  ```typescript
301
- import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@torch-ui/components'
304
+ import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem } from '@torch-ui/components'
302
305
 
303
- function ContextMenu({ x, y }: { x: number; y: number }) {
306
+ function Example() {
304
307
  return (
305
- <DropdownMenu>
306
- <DropdownMenuTrigger asChild>
307
- <div onContextMenu={(e) => e.preventDefault()}>
308
- Right-click me
309
- </div>
310
- </DropdownMenuTrigger>
311
- <DropdownMenuContent>
312
- <DropdownMenuItem>View</DropdownMenuItem>
313
- <DropdownMenuItem>Edit</DropdownMenuItem>
314
- <DropdownMenuSeparator />
315
- <DropdownMenuItem>Properties</DropdownMenuItem>
316
- </DropdownMenuContent>
317
- </DropdownMenu>
308
+ <ContextMenu>
309
+ <ContextMenuTrigger className="rounded-md border border-dashed p-8">
310
+ Right-click here
311
+ </ContextMenuTrigger>
312
+ <ContextMenuContent>
313
+ <ContextMenuItem>View</ContextMenuItem>
314
+ <ContextMenuItem>Edit</ContextMenuItem>
315
+ <ContextMenuItem variant="Negative">Delete</ContextMenuItem>
316
+ </ContextMenuContent>
317
+ </ContextMenu>
318
318
  )
319
319
  }
320
320
  ```
@@ -344,13 +344,15 @@ function ContextMenu({ x, y }: { x: number; y: number }) {
344
344
  | `theme` | `'dark' \| 'light' \| 'default'` | - | Theme variant |
345
345
  | `className` | `string` | - | Additional CSS classes |
346
346
  | `sideOffset` | `number` | `4` | Distance from trigger |
347
+ | `collisionPadding` | `number` | `8` | Gap kept from viewport edges when flipping/shifting |
347
348
  | `align` | `'start' \| 'center' \| 'end'` | `'start'` | Alignment |
349
+ | `autoGroup` | `boolean` | `true` | Auto-wrap loose items in boxed groups |
348
350
 
349
351
  ### DropdownMenuItem
350
352
 
351
353
  | Prop | Type | Default | Description |
352
354
  |------|------|---------|-------------|
353
- | `variant` | `'Default' \| 'Warning' \| 'Negative' \| 'SystemStyle'` | `'Default'` | Item style variant |
355
+ | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Item style variant |
354
356
  | `size` | `'S' \| 'M'` | `'M'` | Item size |
355
357
  | `disabled` | `boolean` | `false` | Disabled state |
356
358
  | `active` | `boolean` | `false` | Active state |
@@ -362,7 +364,7 @@ function ContextMenu({ x, y }: { x: number; y: number }) {
362
364
  |------|------|---------|-------------|
363
365
  | `checked` | `boolean \| 'indeterminate'` | `false` | Checked state |
364
366
  | `onCheckedChange` | `(checked: boolean) => void` | - | Change handler |
365
- | `variant` | `'Default' \| 'Warning' \| 'Negative' \| 'SystemStyle'` | `'Default'` | Style variant |
367
+ | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
366
368
 
367
369
  ### DropdownMenuRadioGroup
368
370
 
@@ -376,7 +378,7 @@ function ContextMenu({ x, y }: { x: number; y: number }) {
376
378
  | Prop | Type | Default | Description |
377
379
  |------|------|---------|-------------|
378
380
  | `value` | `string` | Required | Radio option value |
379
- | `variant` | `'Default' \| 'Warning' \| 'Negative' \| 'SystemStyle'` | `'Default'` | Style variant |
381
+ | `variant` | `'Default' \| 'info' \| 'Negative'` | `'Default'` | Style variant |
380
382
 
381
383
  ### DropdownMenuLabel
382
384
 
@@ -384,10 +386,11 @@ function ContextMenu({ x, y }: { x: number; y: number }) {
384
386
  |------|------|---------|-------------|
385
387
  | `className` | `string` | - | Additional CSS classes |
386
388
 
387
- ### DropdownMenuSeparator
389
+ ### DropdownMenuGroup
388
390
 
389
391
  | Prop | Type | Default | Description |
390
392
  |------|------|---------|-------------|
393
+ | `variant` | `'Boxed' \| 'Plain'` | `'Boxed'` | Boxed renders a bordered container; Plain is semantic-only |
391
394
  | `className` | `string` | - | Additional CSS classes |
392
395
 
393
396
  ### DropdownMenuShortcut
@@ -427,7 +430,7 @@ export const DropdownMenuContent: React.ForwardRefExoticComponent<DropdownMenuCo
427
430
 
428
431
  // Item
429
432
  interface DropdownMenuItemProps {
430
- variant?: 'Default' | 'Warning' | 'Negative' | 'SystemStyle'
433
+ variant?: 'Default' | 'info' | 'Negative'
431
434
  size?: 'S' | 'M'
432
435
  disabled?: boolean
433
436
  active?: boolean
@@ -440,7 +443,7 @@ export const DropdownMenuItem: React.ForwardRefExoticComponent<DropdownMenuItemP
440
443
  interface DropdownMenuCheckboxItemProps {
441
444
  checked?: boolean | 'indeterminate'
442
445
  onCheckedChange?: (checked: boolean) => void
443
- variant?: 'Default' | 'Warning' | 'Negative' | 'SystemStyle'
446
+ variant?: 'Default' | 'info' | 'Negative'
444
447
  }
445
448
 
446
449
  export const DropdownMenuCheckboxItem: React.ForwardRefExoticComponent<DropdownMenuCheckboxItemProps>
@@ -448,7 +451,7 @@ export const DropdownMenuCheckboxItem: React.ForwardRefExoticComponent<DropdownM
448
451
  // RadioItem
449
452
  interface DropdownMenuRadioItemProps {
450
453
  value: string
451
- variant?: 'Default' | 'Warning' | 'Negative' | 'SystemStyle'
454
+ variant?: 'Default' | 'info' | 'Negative'
452
455
  }
453
456
 
454
457
  export const DropdownMenuRadioItem: React.ForwardRefExoticComponent<DropdownMenuRadioItemProps>
@@ -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` | `'SystemStyle' \| '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?: 'SystemStyle' | '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