torch-glare 2.1.7 → 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.
@@ -0,0 +1,419 @@
1
+ ---
2
+ title: SearchableTable
3
+ description: A searchable combobox whose dropdown renders a real Table, with single-select, client- or server-side search, and infinite-scroll pagination
4
+ group: Inputs
5
+ keywords: [searchable-table, combobox, table, search, async, infinite-scroll, pagination, select]
6
+ ---
7
+
8
+ # SearchableTable
9
+
10
+ > A searchable combobox that renders its options as a real, multi-column `Table` inside a `Popover`. Type to filter rows (client-side) or refetch them from a backend (server-side), click a row to single-select it, and scroll to the bottom to lazy-load more pages. Generic over your row type `T`.
11
+
12
+ ## Installation
13
+
14
+ `SearchableTable` is part of the TORCH Glare component library. It composes the [Popover](./popover.md) (built on Radix Popover) and the [Table](./table.md) component internally, so both must be available — they ship with the library.
15
+
16
+ ```bash
17
+ npm install @torch-ui/components @radix-ui/react-popover
18
+ ```
19
+
20
+ ## Import
21
+
22
+ ```typescript
23
+ import { SearchableTable } from '@torch-ui/components'
24
+ import type { SearchableTableColumn } from '@torch-ui/components'
25
+ ```
26
+
27
+ ## Quick Examples
28
+
29
+ ### Basic (static rows)
30
+
31
+ Provide a `columns` config and a `rows` array. Single-select is controlled via `value` / `onSelect`. Use `getLabel` to control the text shown in the input after selection, and `getRowId` to give each row a stable key.
32
+
33
+ ```typescript
34
+ import { SearchableTable } from '@torch-ui/components'
35
+ import type { SearchableTableColumn } from '@torch-ui/components'
36
+ import { useState } from 'react'
37
+
38
+ type User = { id: string; name: string; role: string; email: string }
39
+
40
+ const users: User[] = [
41
+ { id: '1', name: 'Ada Lovelace', role: 'Engineer', email: 'ada@torch.dev' },
42
+ { id: '2', name: 'Alan Turing', role: 'Researcher', email: 'alan@torch.dev' },
43
+ { id: '3', name: 'Grace Hopper', role: 'Admiral', email: 'grace@torch.dev' },
44
+ ]
45
+
46
+ const columns: SearchableTableColumn<User>[] = [
47
+ { key: 'name', header: 'Name' },
48
+ { key: 'role', header: 'Role' },
49
+ { key: 'email', header: 'Email' },
50
+ ]
51
+
52
+ function Example() {
53
+ const [selected, setSelected] = useState<User | null>(null)
54
+
55
+ return (
56
+ <SearchableTable<User>
57
+ columns={columns}
58
+ rows={users}
59
+ value={selected}
60
+ onSelect={setSelected}
61
+ getLabel={(row) => row.name}
62
+ getRowId={(row) => row.id}
63
+ placeholder="Search users…"
64
+ />
65
+ )
66
+ }
67
+ ```
68
+
69
+ The input opens its dropdown on focus. As you type, rows are filtered locally by every column key. Clicking a row selects it (single-select), closes the dropdown, and shows `getLabel(row)` in the input.
70
+
71
+ ### Custom cell rendering
72
+
73
+ Use a column's `render` to control how a cell is displayed. When omitted, the cell falls back to `String(row[key])`.
74
+
75
+ ```typescript
76
+ const columns: SearchableTableColumn<User>[] = [
77
+ { key: 'name', header: 'Name' },
78
+ {
79
+ key: 'role',
80
+ header: 'Role',
81
+ render: (row) => (
82
+ <span className="rounded bg-white-alpha-20 px-2 py-0.5">{row.role}</span>
83
+ ),
84
+ },
85
+ {
86
+ key: 'email',
87
+ header: 'Contact',
88
+ render: (row) => <a href={`mailto:${row.email}`}>{row.email}</a>,
89
+ },
90
+ ]
91
+ ```
92
+
93
+ ### Async server search + infinite scroll
94
+
95
+ Set `filterClientSide={false}` and treat `rows` as already-filtered server data. `onSearchChange` fires (debounced by `searchDebounceMs`, default 300ms) whenever the query settles — refetch there. As the list nears the bottom, `onLoadMore` fires while `hasMore && !loading`; a loading row renders whenever `loading` is true.
96
+
97
+ ```typescript
98
+ import { SearchableTable } from '@torch-ui/components'
99
+ import type { SearchableTableColumn } from '@torch-ui/components'
100
+ import { useEffect, useState } from 'react'
101
+
102
+ type User = { id: string; name: string; role: string; email: string }
103
+
104
+ const columns: SearchableTableColumn<User>[] = [
105
+ { key: 'name', header: 'Name' },
106
+ { key: 'role', header: 'Role' },
107
+ { key: 'email', header: 'Email' },
108
+ ]
109
+
110
+ async function fetchUsers(query: string, page: number) {
111
+ const res = await fetch(`/api/users?q=${encodeURIComponent(query)}&page=${page}`)
112
+ return res.json() as Promise<{ items: User[]; hasMore: boolean }>
113
+ }
114
+
115
+ function AsyncExample() {
116
+ const [rows, setRows] = useState<User[]>([])
117
+ const [query, setQuery] = useState('')
118
+ const [page, setPage] = useState(1)
119
+ const [hasMore, setHasMore] = useState(true)
120
+ const [loading, setLoading] = useState(false)
121
+ const [selected, setSelected] = useState<User | null>(null)
122
+
123
+ // Refetch from page 1 whenever the debounced query changes.
124
+ useEffect(() => {
125
+ let cancelled = false
126
+ setLoading(true)
127
+ fetchUsers(query, 1).then(({ items, hasMore }) => {
128
+ if (cancelled) return
129
+ setRows(items)
130
+ setHasMore(hasMore)
131
+ setPage(1)
132
+ setLoading(false)
133
+ })
134
+ return () => {
135
+ cancelled = true
136
+ }
137
+ }, [query])
138
+
139
+ const loadMore = async () => {
140
+ const next = page + 1
141
+ setLoading(true)
142
+ const { items, hasMore } = await fetchUsers(query, next)
143
+ setRows((prev) => [...prev, ...items])
144
+ setHasMore(hasMore)
145
+ setPage(next)
146
+ setLoading(false)
147
+ }
148
+
149
+ return (
150
+ <SearchableTable<User>
151
+ columns={columns}
152
+ rows={rows}
153
+ value={selected}
154
+ onSelect={setSelected}
155
+ getRowId={(row) => row.id}
156
+ getLabel={(row) => row.name}
157
+ filterClientSide={false}
158
+ onSearchChange={setQuery}
159
+ hasMore={hasMore}
160
+ loading={loading}
161
+ onLoadMore={loadMore}
162
+ placeholder="Search users…"
163
+ />
164
+ )
165
+ }
166
+ ```
167
+
168
+ ### Capping the visible height
169
+
170
+ `maxVisibleRows` (default `6`) caps how many rows are visible before the dropdown scrolls vertically. The remaining rows stay reachable via scroll, which is also what drives infinite-scroll loading.
171
+
172
+ ```typescript
173
+ <SearchableTable<User>
174
+ columns={columns}
175
+ rows={users}
176
+ onSelect={setSelected}
177
+ getRowId={(row) => row.id}
178
+ maxVisibleRows={4}
179
+ />
180
+ ```
181
+
182
+ ## API Reference
183
+
184
+ ### SearchableTable&lt;T&gt;
185
+
186
+ | Prop | Type | Default | Description |
187
+ |------|------|---------|-------------|
188
+ | `columns` | `SearchableTableColumn<T>[]` | Required | Column config — header, the row key to read, and an optional cell renderer. |
189
+ | `rows` | `T[]` | Required | The data rows. In server mode these are rendered as-is (already filtered upstream). |
190
+ | `value` | `T \| null` | - | Controlled selected row. The matching row is highlighted in the table. |
191
+ | `onSelect` | `(row: T) => void` | - | Called when a row is clicked. The dropdown closes and the input shows the row's label. |
192
+ | `getLabel` | `(row: T) => string` | First column's value | Text shown in the input after selection. |
193
+ | `getRowId` | `(row: T) => string` | `JSON.stringify(row)` | Stable id/key per row, used for keys and selection matching. |
194
+ | `searchKeys` | `(keyof T & string)[]` | Every column `key` | Which fields client-side search matches against. |
195
+ | `placeholder` | `string` | `'Search…'` | Input placeholder text. |
196
+ | `size` | `'XS' \| 'S' \| 'M'` | `'M'` | Input size. (`XS` maps the underlying Group to `S` with a tighter input height.) |
197
+ | `variant` | `'SystemStyle' \| 'PresentationStyle'` | `'PresentationStyle'` | Visual style of the input and dropdown surface. |
198
+ | `icon` | `ReactNode` | - | Optional leading icon rendered inside the input. |
199
+ | `theme` | `'dark' \| 'light' \| 'default'` | - | Theme variant, applied via `data-theme`. |
200
+ | `dir` | `string` | - | Text direction (e.g. `'rtl'`), applied to the input group and dropdown. |
201
+ | `className` | `string` | - | Additional classes merged onto the input group. |
202
+ | `filterClientSide` | `boolean` | `true` | When `true`, filter `rows` locally by `searchKeys`. Set `false` for server-side search. |
203
+ | `onSearchChange` | `(query: string) => void` | - | Debounced query callback — refetch your data here in server mode. |
204
+ | `searchDebounceMs` | `number` | `300` | Debounce delay (ms) for `onSearchChange`. |
205
+ | `hasMore` | `boolean` | `false` | Whether more pages are available; gates the infinite-scroll loader. |
206
+ | `loading` | `boolean` | `false` | Whether a fetch is in flight; renders a loading row and blocks `onLoadMore`. |
207
+ | `onLoadMore` | `() => void` | - | Called when the list nears the bottom and `hasMore && !loading`. |
208
+ | `maxVisibleRows` | `number` | `6` | Max rows visible before the list scrolls vertically. |
209
+
210
+ ### SearchableTableColumn&lt;T&gt;
211
+
212
+ | Prop | Type | Default | Description |
213
+ |------|------|---------|-------------|
214
+ | `key` | `keyof T & string` | Required | Property on the row used for default rendering and search. |
215
+ | `header` | `ReactNode` | Required | Column header content. |
216
+ | `render` | `(row: T) => ReactNode` | `String(row[key])` | Custom cell renderer. |
217
+
218
+ ## TypeScript
219
+
220
+ ### SearchableTableColumn
221
+
222
+ ```typescript
223
+ export interface SearchableTableColumn<T> {
224
+ /** Property on the row used for default rendering and search. */
225
+ key: keyof T & string
226
+ header: React.ReactNode
227
+ /** Custom cell renderer; defaults to String(row[key]). */
228
+ render?: (row: T) => React.ReactNode
229
+ }
230
+ ```
231
+
232
+ ### Component signature
233
+
234
+ ```typescript
235
+ import { Themes } from '@torch-ui/components'
236
+
237
+ interface SearchableTableProps<T> {
238
+ columns: SearchableTableColumn<T>[]
239
+ rows: T[]
240
+ value?: T | null
241
+ onSelect?: (row: T) => void
242
+ getLabel?: (row: T) => string
243
+ getRowId?: (row: T) => string
244
+ searchKeys?: (keyof T & string)[]
245
+ placeholder?: string
246
+ size?: 'XS' | 'S' | 'M'
247
+ variant?: 'SystemStyle' | 'PresentationStyle'
248
+ icon?: React.ReactNode
249
+ theme?: Themes
250
+ dir?: string
251
+ className?: string
252
+ // Async / backend pagination
253
+ filterClientSide?: boolean
254
+ onSearchChange?: (query: string) => void
255
+ searchDebounceMs?: number
256
+ hasMore?: boolean
257
+ loading?: boolean
258
+ onLoadMore?: () => void
259
+ maxVisibleRows?: number
260
+ }
261
+
262
+ // Generic function component, constrained so rows are object-shaped:
263
+ export function SearchableTable<T extends Record<string, unknown>>(
264
+ props: SearchableTableProps<T>
265
+ ): JSX.Element
266
+ ```
267
+
268
+ Always pass the type argument explicitly (`<SearchableTable<User> … />`) so `columns`, `value`, and the callbacks are fully type-checked against your row shape.
269
+
270
+ ## Common Patterns
271
+
272
+ ### React Query — `useInfiniteQuery`
273
+
274
+ Wire pagination through `useInfiniteQuery`, flatten the pages into `rows`, and map the query's state onto `hasMore` / `loading` / `onLoadMore`.
275
+
276
+ ```typescript
277
+ import { SearchableTable } from '@torch-ui/components'
278
+ import type { SearchableTableColumn } from '@torch-ui/components'
279
+ import { useInfiniteQuery } from '@tanstack/react-query'
280
+ import { useState } from 'react'
281
+
282
+ type User = { id: string; name: string; role: string; email: string }
283
+
284
+ const columns: SearchableTableColumn<User>[] = [
285
+ { key: 'name', header: 'Name' },
286
+ { key: 'role', header: 'Role' },
287
+ { key: 'email', header: 'Email' },
288
+ ]
289
+
290
+ function UserPicker() {
291
+ const [query, setQuery] = useState('')
292
+ const [selected, setSelected] = useState<User | null>(null)
293
+
294
+ const {
295
+ data,
296
+ fetchNextPage,
297
+ hasNextPage,
298
+ isFetching,
299
+ } = useInfiniteQuery({
300
+ queryKey: ['users', query],
301
+ queryFn: async ({ pageParam = 1 }) => {
302
+ const res = await fetch(`/api/users?q=${query}&page=${pageParam}`)
303
+ // API row -> T mapping happens here.
304
+ const json = await res.json()
305
+ return {
306
+ items: json.results.map(
307
+ (r: any): User => ({
308
+ id: String(r.user_id),
309
+ name: r.full_name,
310
+ role: r.role_title,
311
+ email: r.email_address,
312
+ })
313
+ ),
314
+ nextPage: json.has_more ? pageParam + 1 : undefined,
315
+ }
316
+ },
317
+ initialPageParam: 1,
318
+ getNextPageParam: (last) => last.nextPage,
319
+ })
320
+
321
+ const rows = data?.pages.flatMap((p) => p.items) ?? []
322
+
323
+ return (
324
+ <SearchableTable<User>
325
+ columns={columns}
326
+ rows={rows}
327
+ value={selected}
328
+ onSelect={setSelected}
329
+ getRowId={(row) => row.id}
330
+ getLabel={(row) => row.name}
331
+ filterClientSide={false}
332
+ onSearchChange={setQuery}
333
+ hasMore={hasNextPage}
334
+ loading={isFetching}
335
+ onLoadMore={() => fetchNextPage()}
336
+ />
337
+ )
338
+ }
339
+ ```
340
+
341
+ ### SWR — `useSWRInfinite`
342
+
343
+ ```typescript
344
+ import { SearchableTable } from '@torch-ui/components'
345
+ import useSWRInfinite from 'swr/infinite'
346
+ import { useState } from 'react'
347
+
348
+ const PAGE_SIZE = 20
349
+
350
+ function UserPicker() {
351
+ const [query, setQuery] = useState('')
352
+ const [selected, setSelected] = useState<User | null>(null)
353
+
354
+ const getKey = (index: number, prev: { items: User[] } | null) => {
355
+ if (prev && prev.items.length < PAGE_SIZE) return null // reached the end
356
+ return `/api/users?q=${query}&page=${index + 1}`
357
+ }
358
+
359
+ const { data, size, setSize, isValidating } = useSWRInfinite(getKey, fetcher)
360
+
361
+ const rows = data?.flatMap((p) => p.items) ?? []
362
+ const hasMore = !!data && data[data.length - 1]?.items.length === PAGE_SIZE
363
+
364
+ return (
365
+ <SearchableTable<User>
366
+ columns={columns}
367
+ rows={rows}
368
+ value={selected}
369
+ onSelect={setSelected}
370
+ getRowId={(row) => row.id}
371
+ filterClientSide={false}
372
+ onSearchChange={setQuery}
373
+ hasMore={hasMore}
374
+ loading={isValidating}
375
+ onLoadMore={() => setSize(size + 1)}
376
+ />
377
+ )
378
+ }
379
+ ```
380
+
381
+ ### Mapping API rows to `T`
382
+
383
+ Normalize the backend shape into your row type at the fetch boundary so that `columns`, `getLabel`, `getRowId`, and `searchKeys` all operate on consistent, typed fields:
384
+
385
+ ```typescript
386
+ function toUser(api: ApiUser): User {
387
+ return {
388
+ id: String(api.user_id),
389
+ name: `${api.first_name} ${api.last_name}`,
390
+ role: api.role ?? '—',
391
+ email: api.email,
392
+ }
393
+ }
394
+ ```
395
+
396
+ ## Accessibility
397
+
398
+ - The trigger is a standard text `<input>`, so it is focusable and typeable by keyboard. Focusing it opens the dropdown and starts a fresh search.
399
+ - The chevron is a boxed icon button with `aria-label` toggling between `"Open"` and `"Close"`; it is `tabIndex={-1}` so keyboard focus stays on the input, and it uses `onMouseDown` with `preventDefault` to avoid the input blur/focus race.
400
+ - Clicking outside the input group or the dropdown closes it (handled via `useClickOutside`).
401
+ - The dropdown surface does not steal focus on open (`onOpenAutoFocus` is prevented), keeping the caret in the input while results update.
402
+ - Provide a descriptive `placeholder` and a meaningful `getLabel` so screen-reader users hear a clear value after selection. Prefer human-readable headers in `columns`.
403
+
404
+ ## Best Practices
405
+
406
+ 1. **Always supply `getRowId`.** The default key is `JSON.stringify(row)`, which is slow and brittle for large or nested rows. A stable id keeps keys and selection matching cheap.
407
+ 2. **Set `getLabel` for the selected display.** Otherwise the input shows the first column's raw value, which may not be the most descriptive field.
408
+ 3. **Pick the right search mode.** Keep `filterClientSide` (default) for small, in-memory datasets. Switch to `filterClientSide={false}` + `onSearchChange` for backend-driven search so you never filter a partial page.
409
+ 4. **Gate pagination correctly.** `onLoadMore` only fires while `hasMore && !loading` — keep `loading` accurate so a single scroll doesn't trigger duplicate fetches, and flip `hasMore` to `false` on the last page.
410
+ 5. **The table scrolls in both directions.** Columns keep their natural width, so wide tables scroll horizontally inside the dropdown, while `maxVisibleRows` caps the vertical height and the rest scrolls vertically (which is what drives infinite scroll). Keep column count and content reasonable so horizontal scroll stays usable.
411
+ 6. **Tune `searchDebounceMs` to your backend.** The 300ms default suits most APIs; raise it for slow or rate-limited endpoints.
412
+ 7. **Match `searchKeys` to visible columns.** In client mode, search defaults to every column key — narrow `searchKeys` if some columns hold non-searchable data (ids, formatted dates).
413
+
414
+ ## Related Components
415
+
416
+ - [SearchableSelect](./searchable-select.md) - Single-column searchable combobox
417
+ - [Table](./table.md) - The table primitive rendered inside the dropdown
418
+ - [DataTable](./data-table.md) - Full-featured data grid for page-level tables
419
+ - [Select](./select.md) - Standard form select field
@@ -221,8 +221,28 @@ export default {
221
221
 
222
222
  ### Import CSS Variables
223
223
 
224
+ `tailwindVars.css` ships the `@theme` block that registers every `--color-*`
225
+ token (badge, action, state, etc.) as a Tailwind utility. Without it, classes
226
+ like `bg-background-presentation-badge-gray-solid` resolve to nothing and
227
+ components render with no background.
228
+
229
+ **Ordering is critical.** CSS requires all `@import` statements to come before
230
+ any other at-rule. Place this `@import` **above** every `@plugin` and `@source`
231
+ rule — directly under `@import "tailwindcss"`. If it sits after them, the
232
+ bundler (Vite/Lightning CSS, PostCSS) treats it as a misplaced `@import` and
233
+ **silently drops it**, removing the entire color registration. The tell-tale
234
+ build warning is `@import must precede all other statements`.
235
+
236
+ ```css
237
+ /* app.css or globals.css — Tailwind v4 */
238
+ @import "tailwindcss";
239
+ @import "mapping-color-system-v4/tailwindVars.css"; /* MUST be before @plugin/@source */
240
+ @plugin "glare-torch-mode";
241
+ @plugin "mapping-color-system-v4";
242
+ ```
243
+
224
244
  ```css
225
- /* app.css or globals.css */
245
+ /* Tailwind v3 */
226
246
  @import 'mapping-color-system-v4/tailwindVars.css';
227
247
 
228
248
  @tailwind base;
@@ -89,14 +89,28 @@ Add the following to your `global.css` file:
89
89
 
90
90
  ```css
91
91
  @import "tailwindcss";
92
+ /* IMPORTANT: this @import MUST come before any @plugin or @source rule.
93
+ CSS requires all @import statements to precede other at-rules, so if it
94
+ is placed after the @plugin lines the bundler (Vite/Lightning CSS,
95
+ PostCSS) silently drops it. That removes every --color-* registration
96
+ this file provides and breaks ALL bg/text/border-*-presentation-* utilities
97
+ (e.g. Badge renders with no background). Keep it directly under
98
+ @import "tailwindcss". */
99
+ @import "mapping-color-system-v4/tailwindVars.css";
92
100
  @plugin "glare-torch-mode";
93
101
  @plugin "tailwind-scrollbar-hide";
94
102
  @plugin "tailwindcss-animate";
95
103
  @plugin "glare-typography";
96
104
  @plugin "mapping-color-system-v4";
97
- @import "mapping-color-system-v4/tailwindVars.css";
98
105
  ```
99
106
 
107
+ > ⚠️ **Common failure:** if your Badge (or any component using
108
+ > `bg-background-presentation-*` colors) renders with no background, the
109
+ > `tailwindVars.css` `@import` is almost certainly positioned **after** the
110
+ > `@plugin` rules and is being dropped as an invalid `@import`. Move it up,
111
+ > directly below `@import "tailwindcss"`, and rebuild. Look for the build
112
+ > warning `@import must precede all other statements` — that confirms it.
113
+
100
114
  ### For Tailwind CSS v3
101
115
 
102
116
  First install the mapping color system:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torch-glare",
3
- "version": "2.1.7",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "files": [