@charcuterie/ui 4.25.0 → 4.26.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.
Files changed (54) hide show
  1. package/README.md +13 -0
  2. package/dist/CommandPalette/CommandPalette.d.ts +43 -0
  3. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -0
  4. package/dist/CommandPalette/CommandPalette.js +53 -0
  5. package/dist/CommandPalette/CommandPalette.js.map +1 -0
  6. package/dist/CommandPalette/CommandPaletteFooter.d.ts +16 -0
  7. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -0
  8. package/dist/CommandPalette/CommandPaletteFooter.js +14 -0
  9. package/dist/CommandPalette/CommandPaletteFooter.js.map +1 -0
  10. package/dist/CommandPalette/CommandPaletteOption.d.ts +44 -0
  11. package/dist/CommandPalette/CommandPaletteOption.d.ts.map +1 -0
  12. package/dist/CommandPalette/CommandPaletteOption.js +72 -0
  13. package/dist/CommandPalette/CommandPaletteOption.js.map +1 -0
  14. package/dist/CommandPalette/CommandPalettePanel.d.ts +52 -0
  15. package/dist/CommandPalette/CommandPalettePanel.d.ts.map +1 -0
  16. package/dist/CommandPalette/CommandPalettePanel.js +168 -0
  17. package/dist/CommandPalette/CommandPalettePanel.js.map +1 -0
  18. package/dist/CommandPalette/commandPaletteSections.d.ts +93 -0
  19. package/dist/CommandPalette/commandPaletteSections.d.ts.map +1 -0
  20. package/dist/CommandPalette/commandPaletteSections.js +94 -0
  21. package/dist/CommandPalette/commandPaletteSections.js.map +1 -0
  22. package/dist/CommandPalette/useCommandPalette.d.ts +32 -0
  23. package/dist/CommandPalette/useCommandPalette.d.ts.map +1 -0
  24. package/dist/CommandPalette/useCommandPalette.js +40 -0
  25. package/dist/CommandPalette/useCommandPalette.js.map +1 -0
  26. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts +35 -0
  27. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts.map +1 -0
  28. package/dist/CommandPalette/useCommandPaletteHotkeys.js +114 -0
  29. package/dist/CommandPalette/useCommandPaletteHotkeys.js.map +1 -0
  30. package/dist/TableViewport/TableViewport.d.ts.map +1 -1
  31. package/dist/TableViewport/TableViewport.js +62 -10
  32. package/dist/TableViewport/TableViewport.js.map +1 -1
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/CommandPalette/CommandPalette.mdx +129 -0
  39. package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
  40. package/src/CommandPalette/CommandPalette.test.tsx +553 -0
  41. package/src/CommandPalette/CommandPalette.tsx +91 -0
  42. package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
  43. package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
  44. package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
  45. package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
  46. package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
  47. package/src/CommandPalette/commandPaletteSections.ts +219 -0
  48. package/src/CommandPalette/useCommandPalette.ts +75 -0
  49. package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
  50. package/src/TableViewport/TableViewport.mdx +2 -0
  51. package/src/TableViewport/TableViewport.test.tsx +89 -1
  52. package/src/TableViewport/TableViewport.tsx +82 -9
  53. package/src/index.ts +14 -0
  54. package/src/sourceRules.test.ts +2 -1
@@ -0,0 +1,200 @@
1
+ import type { TextMatchRange } from "@charcuterie/logic"
2
+ import type { ControlSize } from "@charcuterie/tokens"
3
+ import type {
4
+ MouseEvent as ReactMouseEvent,
5
+ ReactNode,
6
+ } from "react"
7
+
8
+ import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
9
+ import { UnstyledLink } from "../RouterLink/UnstyledLink.tsx"
10
+ import { toClassName } from "../toClassName.ts"
11
+ import type { CommandPaletteItem } from "./commandPaletteSections.ts"
12
+
13
+ export type CommandPaletteOptionProps = {
14
+ /** The DOM id `aria-activedescendant` points at. */
15
+ id: string
16
+ isActive: boolean
17
+ item: CommandPaletteItem
18
+ itemSize: ControlSize
19
+ /** A click on the row: the link has navigated (or will), so close and run `onSelect`. */
20
+ onChoose: () => void
21
+ /** The pointer moved over the row. */
22
+ onPoint: () => void
23
+ titleRanges: readonly TextMatchRange[]
24
+ }
25
+
26
+ const ICON_SIZE_CLASS: Record<ControlSize, string> = {
27
+ lg: "size-7 [&>img]:size-7 [&>svg]:size-5",
28
+ md: "size-6 [&>img]:size-6 [&>svg]:size-4",
29
+ sm: "size-5 [&>img]:size-5 [&>svg]:size-4",
30
+ }
31
+
32
+ /**
33
+ * The title with each matched range wrapped in a `<mark>`. Plain
34
+ * text in, so nothing the caller typed is ever read as markup.
35
+ */
36
+ const HighlightedTitle = ({
37
+ ranges,
38
+ title,
39
+ }: {
40
+ ranges: readonly TextMatchRange[]
41
+ title: string
42
+ }): ReactNode => {
43
+ if (ranges.length === 0) {
44
+ return title
45
+ }
46
+
47
+ const pieces: ReactNode[] = []
48
+ let cursor = 0
49
+
50
+ for (const { end, start } of ranges) {
51
+ if (start > cursor) {
52
+ pieces.push(title.slice(cursor, start))
53
+ }
54
+
55
+ pieces.push(
56
+ <mark
57
+ className="bg-transparent font-semibold text-intent-accent-content"
58
+ key={start}
59
+ >
60
+ {title.slice(start, end)}
61
+ </mark>,
62
+ )
63
+
64
+ cursor = end
65
+ }
66
+
67
+ if (cursor < title.length) {
68
+ pieces.push(title.slice(cursor))
69
+ }
70
+
71
+ return pieces
72
+ }
73
+
74
+ /**
75
+ * One row of the palette's listbox.
76
+ *
77
+ * **Focus never comes here.** The input keeps it, and the row the
78
+ * arrows have reached is announced through the input's
79
+ * `aria-activedescendant` — the APG combobox pattern `Combobox` uses
80
+ * too. So the row is `tabIndex={-1}`, and a mouse press is prevented
81
+ * from moving focus (`onMouseDown`), which keeps the caret in the
82
+ * input while the click still lands.
83
+ *
84
+ * **An `href` item is a real `<a href>`.** It goes through
85
+ * `UnstyledLink`, so an app's injected router link handles a plain
86
+ * click, and middle-click, ctrl-click and "copy link address" all
87
+ * work, because it is a link. Enter in the input clicks this same
88
+ * element, so the keyboard and the mouse take one path. An item
89
+ * without one is a `<button>`, as in `Combobox`.
90
+ *
91
+ * **The pointer moves the active row on `pointermove`, not
92
+ * `pointerenter`.** Arrowing scrolls the list under a mouse that has
93
+ * not moved, and an enter event would then hand the active row to
94
+ * whatever slid under the pointer.
95
+ *
96
+ * **One highlight.** The active row is tinted and nothing else is —
97
+ * no separate `:hover` tint — because the pointer makes a row active,
98
+ * and two tinted rows would leave Enter's target ambiguous.
99
+ */
100
+ export const CommandPaletteOption = ({
101
+ id,
102
+ isActive,
103
+ item,
104
+ itemSize,
105
+ onChoose,
106
+ onPoint,
107
+ titleRanges,
108
+ }: CommandPaletteOptionProps): ReactNode => {
109
+ const { description, hint, href, icon, target, title } =
110
+ item
111
+
112
+ const className = toClassName(
113
+ "flex w-full cursor-pointer items-center rounded-md text-start text-content-primary no-underline",
114
+ PANEL_ITEM_SIZE_CLASS[itemSize],
115
+ isActive && "bg-intent-neutral-surface-hover",
116
+ )
117
+
118
+ const content = (
119
+ <>
120
+ {icon === undefined ? null : (
121
+ <span
122
+ aria-hidden="true"
123
+ className={toClassName(
124
+ "flex shrink-0 items-center justify-center overflow-hidden rounded-sm text-content-secondary [&>img]:object-contain",
125
+ ICON_SIZE_CLASS[itemSize],
126
+ )}
127
+ >
128
+ {icon}
129
+ </span>
130
+ )}
131
+
132
+ <span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2">
133
+ <span className="min-w-0 wrap-anywhere">
134
+ <HighlightedTitle
135
+ ranges={titleRanges}
136
+ title={title}
137
+ />
138
+ </span>
139
+
140
+ {description === undefined ? null : (
141
+ <span className="min-w-0 text-content-muted text-sm wrap-anywhere">
142
+ {description}
143
+ </span>
144
+ )}
145
+ </span>
146
+
147
+ {hint === undefined ? null : (
148
+ <span className="shrink-0 text-content-muted text-sm">
149
+ {hint}
150
+ </span>
151
+ )}
152
+ </>
153
+ )
154
+
155
+ const keepFocusInInput = (
156
+ mouseEvent: ReactMouseEvent<HTMLElement>,
157
+ ) => {
158
+ mouseEvent.preventDefault()
159
+ }
160
+
161
+ if (href !== undefined) {
162
+ return (
163
+ <UnstyledLink
164
+ aria-selected={isActive}
165
+ className={className}
166
+ href={href}
167
+ id={id}
168
+ onClick={onChoose}
169
+ onMouseDown={keepFocusInInput}
170
+ onPointerMove={onPoint}
171
+ rel={
172
+ target === "_blank"
173
+ ? "noopener noreferrer"
174
+ : undefined
175
+ }
176
+ role="option"
177
+ tabIndex={-1}
178
+ target={target}
179
+ >
180
+ {content}
181
+ </UnstyledLink>
182
+ )
183
+ }
184
+
185
+ return (
186
+ <button
187
+ aria-selected={isActive}
188
+ className={className}
189
+ id={id}
190
+ onClick={onChoose}
191
+ onMouseDown={keepFocusInInput}
192
+ onPointerMove={onPoint}
193
+ role="option"
194
+ tabIndex={-1}
195
+ type="button"
196
+ >
197
+ {content}
198
+ </button>
199
+ )
200
+ }
@@ -0,0 +1,411 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { ControlSize } from "@charcuterie/tokens"
3
+ import type {
4
+ KeyboardEvent as ReactKeyboardEvent,
5
+ ReactNode,
6
+ RefObject,
7
+ } from "react"
8
+ import { useEffect, useMemo, useRef, useState } from "react"
9
+
10
+ import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
11
+ import { SearchInput } from "../SearchInput/SearchInput.tsx"
12
+ import { toClassName } from "../toClassName.ts"
13
+ import { CommandPaletteFooter } from "./CommandPaletteFooter.tsx"
14
+ import { CommandPaletteOption } from "./CommandPaletteOption.tsx"
15
+ import {
16
+ buildCommandPaletteSections,
17
+ type CommandPaletteGroup,
18
+ type CommandPaletteItem,
19
+ type CommandPalettePrefix,
20
+ type CommandPaletteRow,
21
+ } from "./commandPaletteSections.ts"
22
+
23
+ export type CommandPalettePanelProps = {
24
+ /** The glyph inside the clear button. Without one, there is no clear button — Escape closes. */
25
+ clearIcon?: ReactNode
26
+ /** The query the palette opens with, when the caller does not own `query`. Read once per open. */
27
+ defaultQuery?: string
28
+ /** Shown when a query matches nothing and there is no fallback row. Default "No matches". */
29
+ emptyLabel?: string
30
+ /**
31
+ * The strip under the list. Default: the keys, and each prefix
32
+ * with its target. `null` draws none.
33
+ */
34
+ footer?: ReactNode
35
+ /** The title of a prefix command's row. Default `Search <label> for "<query>"`. */
36
+ formatPrefixTitle?: (
37
+ label: string,
38
+ query: string,
39
+ ) => string
40
+ /** The last row for any query that is not a prefix command — the "search the web" row. */
41
+ getFallbackItem?: (
42
+ query: string,
43
+ ) => CommandPaletteItem | null | undefined
44
+ /** Ref to the search field, for the dialog's initial focus. */
45
+ inputRef?: RefObject<HTMLInputElement | null>
46
+ /** How tall each row is — and the search field. Default `lg`, stepped down on a short window. */
47
+ itemSize?: ControlSize
48
+ /** Everything a query can match. Folder members included, flattened. */
49
+ items: readonly CommandPaletteItem[]
50
+ /** The accessible name of the search field and the list. Default "Search". */
51
+ label?: string
52
+ /** At most this many matches under one heading. Default: no cap. */
53
+ maxResultsPerGroup?: number
54
+ /** After a row is chosen. `CommandPalette` closes here. */
55
+ onChoose?: () => void
56
+ /** Every change to the query. With `query`, the caller owns it. */
57
+ onQueryChange?: (query: string) => void
58
+ placeholder?: string
59
+ /** The heading over a prefix command's row. Default "Search a site". */
60
+ prefixGroupLabel?: string
61
+ prefixes?: readonly CommandPalettePrefix[]
62
+ /** Controlled query. Uncontrolled (from `defaultQuery`) when absent. */
63
+ query?: string
64
+ /** What the palette lists before anything is typed — "Pinned", "Recently opened". */
65
+ suggestions?: readonly CommandPaletteGroup[]
66
+ /** The heading over matches whose item has no `group`. Default "Results". */
67
+ ungroupedLabel?: string
68
+ }
69
+
70
+ const defaultFormatPrefixTitle = (
71
+ label: string,
72
+ query: string,
73
+ ) => `Search ${label} for “${query}”`
74
+
75
+ const GROUP_HEADING_INSET_CLASS: Record<
76
+ ControlSize,
77
+ string
78
+ > = {
79
+ lg: "px-(--control-padding-inline-lg)",
80
+ md: "px-(--control-padding-inline-md)",
81
+ sm: "px-(--control-padding-inline-sm)",
82
+ }
83
+
84
+ const SEARCH_SIZE: Record<ControlSize, "lg" | "md"> = {
85
+ lg: "lg",
86
+ md: "md",
87
+ sm: "md",
88
+ }
89
+
90
+ type IndexedRow = CommandPaletteRow & {
91
+ id: string
92
+ index: number
93
+ }
94
+
95
+ /**
96
+ * The palette's contents — the search field, the grouped listbox and
97
+ * the footer — without the dialog around them. `CommandPalette` is
98
+ * this inside a `Modal`; the stories also lay it out inline, so every
99
+ * state can sit on one board.
100
+ */
101
+ export const CommandPalettePanel = ({
102
+ clearIcon,
103
+ defaultQuery = "",
104
+ emptyLabel = "No matches",
105
+ footer,
106
+ formatPrefixTitle = defaultFormatPrefixTitle,
107
+ getFallbackItem,
108
+ inputRef: externalInputRef,
109
+ itemSize: requestedItemSize = "lg",
110
+ items,
111
+ label = "Search",
112
+ maxResultsPerGroup,
113
+ onChoose,
114
+ onQueryChange,
115
+ placeholder = "Search…",
116
+ prefixGroupLabel = "Search a site",
117
+ prefixes = [],
118
+ query,
119
+ suggestions = [],
120
+ ungroupedLabel = "Results",
121
+ }: CommandPalettePanelProps): ReactNode => {
122
+ const itemSize = usePanelItemSize(requestedItemSize)
123
+
124
+ const listId = useUniqueId()
125
+
126
+ const ownInputRef = useRef<HTMLInputElement>(null)
127
+ const inputRef = externalInputRef ?? ownInputRef
128
+
129
+ const [internalQuery, setInternalQuery] =
130
+ useState(defaultQuery)
131
+
132
+ const currentQuery = query ?? internalQuery
133
+
134
+ const [activeIndex, setActiveIndex] = useState(0)
135
+
136
+ const sections = useMemo(
137
+ () =>
138
+ buildCommandPaletteSections({
139
+ formatPrefixTitle,
140
+ getFallbackItem,
141
+ items,
142
+ maxResultsPerGroup,
143
+ prefixGroupLabel,
144
+ prefixes,
145
+ query: currentQuery,
146
+ suggestions,
147
+ ungroupedLabel,
148
+ }),
149
+ [
150
+ currentQuery,
151
+ formatPrefixTitle,
152
+ getFallbackItem,
153
+ items,
154
+ maxResultsPerGroup,
155
+ prefixGroupLabel,
156
+ prefixes,
157
+ suggestions,
158
+ ungroupedLabel,
159
+ ],
160
+ )
161
+
162
+ const indexedSections = useMemo(() => {
163
+ let index = 0
164
+
165
+ return sections.map((section) => ({
166
+ ...section,
167
+ headingId: `${listId}-${section.key}`,
168
+ rows: section.rows.map(
169
+ (row): IndexedRow => ({
170
+ ...row,
171
+ id: `${listId}-${section.key}-${row.item.key}`,
172
+ index: index++,
173
+ }),
174
+ ),
175
+ }))
176
+ }, [listId, sections])
177
+
178
+ const rows = indexedSections.flatMap(
179
+ (section) => section.rows,
180
+ )
181
+
182
+ const resolvedActiveIndex =
183
+ rows.length === 0
184
+ ? -1
185
+ : Math.min(activeIndex, rows.length - 1)
186
+
187
+ const activeRow = rows[resolvedActiveIndex]
188
+
189
+ const setQuery = (nextQuery: string) => {
190
+ if (query === undefined) {
191
+ setInternalQuery(nextQuery)
192
+ }
193
+
194
+ setActiveIndex(0)
195
+
196
+ onQueryChange?.(nextQuery)
197
+ }
198
+
199
+ // The arrows can walk the active row out of the scrolled list; bring
200
+ // it back. `nearest` is a no-op for a row the pointer is already on.
201
+ useEffect(() => {
202
+ if (activeRow === undefined) {
203
+ return
204
+ }
205
+
206
+ document
207
+ .getElementById(activeRow.id)
208
+ ?.scrollIntoView?.({ block: "nearest" })
209
+ }, [activeRow])
210
+
211
+ /**
212
+ * Opening with a typed character puts the caret AFTER it, so the
213
+ * next key extends the query instead of landing in front of it.
214
+ * Once per mount: a later focus (back from the clear button) keeps
215
+ * wherever the person left the caret.
216
+ */
217
+ const hasPlacedCaret = useRef(false)
218
+
219
+ const choose = (row: IndexedRow) => {
220
+ onChoose?.()
221
+ row.item.onSelect?.()
222
+ }
223
+
224
+ const activate = (row: IndexedRow, isNewTab: boolean) => {
225
+ const { href } = row.item
226
+
227
+ if (href === undefined) {
228
+ choose(row)
229
+
230
+ return
231
+ }
232
+
233
+ if (isNewTab) {
234
+ window.open(href, "_blank", "noopener,noreferrer")
235
+ choose(row)
236
+
237
+ return
238
+ }
239
+
240
+ // The row is the `<a href>` itself, so clicking it takes the path
241
+ // a mouse would — the injected router link, `target`, all of it —
242
+ // and its `onClick` runs `choose`.
243
+ document.getElementById(row.id)?.click()
244
+ }
245
+
246
+ const handleKeyDown = (
247
+ keyboardEvent: ReactKeyboardEvent<HTMLInputElement>,
248
+ ) => {
249
+ const lastIndex = rows.length - 1
250
+
251
+ switch (keyboardEvent.key) {
252
+ case "ArrowDown": {
253
+ keyboardEvent.preventDefault()
254
+ setActiveIndex(
255
+ Math.min(resolvedActiveIndex + 1, lastIndex),
256
+ )
257
+
258
+ return
259
+ }
260
+ case "ArrowUp": {
261
+ keyboardEvent.preventDefault()
262
+ setActiveIndex(Math.max(resolvedActiveIndex - 1, 0))
263
+
264
+ return
265
+ }
266
+ case "Home": {
267
+ keyboardEvent.preventDefault()
268
+ setActiveIndex(0)
269
+
270
+ return
271
+ }
272
+ case "End": {
273
+ keyboardEvent.preventDefault()
274
+ setActiveIndex(Math.max(lastIndex, 0))
275
+
276
+ return
277
+ }
278
+ case "Enter": {
279
+ if (activeRow === undefined) {
280
+ return
281
+ }
282
+
283
+ keyboardEvent.preventDefault()
284
+ activate(
285
+ activeRow,
286
+ keyboardEvent.ctrlKey || keyboardEvent.metaKey,
287
+ )
288
+
289
+ return
290
+ }
291
+ default:
292
+ }
293
+ }
294
+
295
+ const hasRows = rows.length > 0
296
+ const isQueryEmpty = currentQuery.trim().length === 0
297
+
298
+ const footerContent =
299
+ footer === undefined ? (
300
+ <CommandPaletteFooter prefixes={prefixes} />
301
+ ) : (
302
+ footer
303
+ )
304
+
305
+ return (
306
+ <div className="flex min-h-0 flex-1 flex-col">
307
+ <div className="shrink-0 border-border-subtle border-b p-2">
308
+ <SearchInput
309
+ aria-activedescendant={activeRow?.id}
310
+ aria-autocomplete="list"
311
+ aria-controls={hasRows ? listId : undefined}
312
+ aria-expanded={hasRows}
313
+ aria-label={label}
314
+ autoCapitalize="off"
315
+ autoComplete="off"
316
+ autoCorrect="off"
317
+ clearIcon={clearIcon ?? null}
318
+ enterKeyHint="go"
319
+ isClearVisible={
320
+ clearIcon !== undefined &&
321
+ currentQuery.length > 0
322
+ }
323
+ onChange={(changeEvent) =>
324
+ setQuery(changeEvent.currentTarget.value)
325
+ }
326
+ onClear={() => setQuery("")}
327
+ onFocus={(focusEvent) => {
328
+ if (hasPlacedCaret.current) {
329
+ return
330
+ }
331
+
332
+ hasPlacedCaret.current = true
333
+
334
+ const { length } =
335
+ focusEvent.currentTarget.value
336
+
337
+ focusEvent.currentTarget.setSelectionRange(
338
+ length,
339
+ length,
340
+ )
341
+ }}
342
+ onKeyDown={handleKeyDown}
343
+ placeholder={placeholder}
344
+ ref={inputRef}
345
+ role="combobox"
346
+ size={SEARCH_SIZE[itemSize]}
347
+ spellCheck={false}
348
+ value={currentQuery}
349
+ />
350
+ </div>
351
+
352
+ {hasRows ? (
353
+ <div
354
+ aria-label={label}
355
+ className="charcuterie-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5"
356
+ id={listId}
357
+ role="listbox"
358
+ >
359
+ {indexedSections.map((section) => (
360
+ // biome-ignore lint/a11y/useSemanticElements: a listbox group is `role="group"` (ARIA APG); a `<fieldset>` is invalid content inside `role="listbox"`.
361
+ <div
362
+ aria-labelledby={section.headingId}
363
+ className="not-first:mt-1"
364
+ key={section.key}
365
+ role="group"
366
+ >
367
+ <div
368
+ className={toClassName(
369
+ GROUP_HEADING_INSET_CLASS[itemSize],
370
+ "pt-1.5 pb-0.5 font-medium text-content-secondary text-xs",
371
+ )}
372
+ id={section.headingId}
373
+ >
374
+ {section.label}
375
+ </div>
376
+
377
+ {section.rows.map((row) => (
378
+ <CommandPaletteOption
379
+ id={row.id}
380
+ isActive={
381
+ row.index === resolvedActiveIndex
382
+ }
383
+ item={row.item}
384
+ itemSize={itemSize}
385
+ key={row.id}
386
+ onChoose={() => choose(row)}
387
+ onPoint={() => {
388
+ if (row.index !== resolvedActiveIndex) {
389
+ setActiveIndex(row.index)
390
+ }
391
+ }}
392
+ titleRanges={row.titleRanges}
393
+ />
394
+ ))}
395
+ </div>
396
+ ))}
397
+ </div>
398
+ ) : isQueryEmpty ? null : (
399
+ <p className="m-0 px-4 py-6 text-center text-content-muted text-sm">
400
+ {emptyLabel}
401
+ </p>
402
+ )}
403
+
404
+ {footerContent === null ? null : (
405
+ <div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 border-border-subtle border-t px-4 py-2 text-content-muted text-xs">
406
+ {footerContent}
407
+ </div>
408
+ )}
409
+ </div>
410
+ )
411
+ }